diff --git a/.changeset/gentle-brooms-sing.md b/.changeset/gentle-brooms-sing.md
new file mode 100644
index 000000000..1dbcf4a5b
--- /dev/null
+++ b/.changeset/gentle-brooms-sing.md
@@ -0,0 +1,12 @@
+---
+"@trigger.dev/integration-kit": patch
+"@trigger.dev/airtable": patch
+"@trigger.dev/shopify": patch
+"@trigger.dev/sdk": patch
+"@trigger.dev/core": patch
+"@trigger.dev/cli": patch
+---
+
+- Simplify `Webhook Triggers` and use the new HTTP Endpoints
+- Add a `Key-Value Store` for use in and outside of Jobs
+- Add a `@trigger.dev/shopify` package
diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx
index 9bb978c28..d38d35c4f 100644
--- a/apps/webapp/app/components/primitives/PageHeader.tsx
+++ b/apps/webapp/app/components/primitives/PageHeader.tsx
@@ -100,7 +100,7 @@ export function PageInfoProperty({
}: {
icon?: string | React.ReactNode;
label?: string;
- value: React.ReactNode;
+ value?: React.ReactNode;
to?: string;
}) {
if (to === undefined) {
@@ -121,17 +121,18 @@ function PageInfoPropertyContent({
}: {
icon?: string | React.ReactNode;
label?: string;
- value: React.ReactNode;
+ value?: React.ReactNode;
}) {
return (
{tabs.map((tab, index) => (
@@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) {
{tab.label}
{isActive || isPending ? (
-
+
) : (
)}
diff --git a/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx
new file mode 100644
index 000000000..46160052c
--- /dev/null
+++ b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx
@@ -0,0 +1,126 @@
+import { StopIcon } from "@heroicons/react/24/outline";
+import { CheckIcon } from "@heroicons/react/24/solid";
+import { RuntimeEnvironmentType } from "@trigger.dev/database";
+import { formatDuration } from "~/utils";
+import { EnvironmentLabel } from "../environments/EnvironmentLabel";
+import { DateTime } from "../primitives/DateTime";
+import { Paragraph } from "../primitives/Paragraph";
+import { Spinner } from "../primitives/Spinner";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+} from "../primitives/Table";
+import { RunStatus } from "./RunStatuses";
+
+type RunTableItem = {
+ id: string;
+ number: number;
+ environment: {
+ type: RuntimeEnvironmentType;
+ };
+ error: string | null;
+ createdAt: Date | null;
+ deliveredAt: Date | null;
+ verified: boolean;
+};
+
+type RunsTableProps = {
+ total: number;
+ hasFilters: boolean;
+ runs: RunTableItem[];
+ isLoading?: boolean;
+ runsParentPath: string;
+};
+
+export function WebhookDeliveryRunsTable({
+ total,
+ hasFilters,
+ runs,
+ isLoading = false,
+ runsParentPath,
+}: RunsTableProps) {
+ return (
+
+
+
+ Run
+ Env
+ Status
+ Last Error
+ Started
+ Duration
+ Verified
+ Created at
+
+
+
+ {total === 0 && !hasFilters ? (
+
+
+
+ ) : runs.length === 0 ? (
+
+
+
+ ) : (
+ runs.map((run) => {
+ return (
+
+ #{run.number}
+
+
+
+
+
+
+ {run.error?.slice(0, 30) ?? "–"}
+ {run.createdAt ? : "–"}
+
+ {formatDuration(run.createdAt, run.deliveredAt, {
+ style: "short",
+ })}
+
+
+ {run.verified ? (
+
+ ) : (
+
+ )}
+
+ {run.createdAt ? : "–"}
+
+ );
+ })
+ )}
+ {isLoading && (
+
+ Loading…
+
+ )}
+
+
+ );
+}
+function NoRuns({ title }: { title: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts
index fe792483c..886b4192d 100644
--- a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts
+++ b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts
@@ -1,11 +1,9 @@
-import { TriggerHttpEndpoint } from "@trigger.dev/database";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
-import { Project } from "~/models/project.server";
-import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
import { getSecretStore } from "~/services/secrets/secretStore.server";
+import { projectPath } from "~/utils/pathBuilder";
export class HttpEndpointPresenter {
#prismaClient: PrismaClient;
@@ -17,10 +15,12 @@ export class HttpEndpointPresenter {
public async call({
userId,
projectSlug,
+ organizationSlug,
httpEndpointKey,
}: {
userId: string;
projectSlug: string;
+ organizationSlug: string;
httpEndpointKey: string;
}) {
const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({
@@ -57,6 +57,12 @@ export class HttpEndpointPresenter {
},
},
},
+ webhook: {
+ select: {
+ id: true,
+ key: true,
+ },
+ },
},
where: {
key: httpEndpointKey,
@@ -138,11 +144,15 @@ export class HttpEndpointPresenter {
?.webhookUrl,
}));
+ const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
+
return {
httpEndpoint: {
...httpEndpoint,
-
httpEndpointEnvironments,
+ webhookLink: httpEndpoint.webhook
+ ? `${projectRootPath}/triggers/webhooks/${httpEndpoint.webhook.id}`
+ : undefined,
},
environments: relevantEnvironments,
unconfiguredEnvironments: relevantEnvironments.filter(
diff --git a/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts
new file mode 100644
index 000000000..3aec19238
--- /dev/null
+++ b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts
@@ -0,0 +1,117 @@
+import { PrismaClient, prisma } from "~/db.server";
+import { Direction } from "./RunListPresenter.server";
+
+type RunListOptions = {
+ userId: string;
+ webhookId: string;
+ direction?: Direction;
+ cursor?: string;
+};
+
+const PAGE_SIZE = 20;
+
+export type WebhookDeliveryList = Awaited
>;
+
+export class WebhookDeliveryListPresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({ userId, webhookId, direction = "forward", cursor }: RunListOptions) {
+ const directionMultiplier = direction === "forward" ? 1 : -1;
+
+ const runs = await this.#prismaClient.webhookRequestDelivery.findMany({
+ select: {
+ id: true,
+ number: true,
+ createdAt: true,
+ deliveredAt: true,
+ verified: true,
+ error: true,
+ environment: {
+ select: {
+ type: true,
+ slug: true,
+ orgMember: {
+ select: {
+ userId: true,
+ },
+ },
+ },
+ },
+ },
+ where: {
+ webhookId,
+ environment: {
+ OR: [
+ {
+ orgMember: null,
+ },
+ {
+ orgMember: {
+ userId,
+ },
+ },
+ ],
+ },
+ },
+ orderBy: [{ id: "desc" }],
+ //take an extra page to tell if there are more
+ take: directionMultiplier * (PAGE_SIZE + 1),
+ //skip the cursor if there is one
+ skip: cursor ? 1 : 0,
+ cursor: cursor
+ ? {
+ id: cursor,
+ }
+ : undefined,
+ });
+
+ const hasMore = runs.length > PAGE_SIZE;
+
+ //get cursors for next and previous pages
+ let next: string | undefined;
+ let previous: string | undefined;
+ switch (direction) {
+ case "forward":
+ previous = cursor ? runs.at(0)?.id : undefined;
+ if (hasMore) {
+ next = runs[PAGE_SIZE - 1]?.id;
+ }
+ break;
+ case "backward":
+ if (hasMore) {
+ previous = runs[1]?.id;
+ next = runs[PAGE_SIZE]?.id;
+ } else {
+ next = runs[PAGE_SIZE - 1]?.id;
+ }
+ break;
+ }
+
+ const runsToReturn =
+ direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
+
+ return {
+ runs: runsToReturn.map((run) => ({
+ id: run.id,
+ number: run.number,
+ createdAt: run.createdAt,
+ deliveredAt: run.deliveredAt,
+ verified: run.verified,
+ error: run.error,
+ environment: {
+ type: run.environment.type,
+ slug: run.environment.slug,
+ userId: run.environment.orgMember?.userId,
+ },
+ })),
+ pagination: {
+ next,
+ previous,
+ },
+ };
+ }
+}
diff --git a/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts
new file mode 100644
index 000000000..5acd8a639
--- /dev/null
+++ b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts
@@ -0,0 +1,96 @@
+import { User, Webhook } from "@trigger.dev/database";
+import { PrismaClient, prisma } from "~/db.server";
+import { Organization } from "~/models/organization.server";
+import { Project } from "~/models/project.server";
+import { Direction } from "./RunListPresenter.server";
+import { organizationPath, projectPath } from "~/utils/pathBuilder";
+import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server";
+
+export class WebhookDeliveryPresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ userId,
+ projectSlug,
+ organizationSlug,
+ webhookId,
+ direction = "forward",
+ cursor,
+ }: {
+ userId: User["id"];
+ projectSlug: Project["slug"];
+ organizationSlug: Organization["slug"];
+ webhookId: Webhook["id"];
+ direction?: Direction;
+ cursor?: string;
+ }) {
+ const webhook = await this.#prismaClient.webhook.findUnique({
+ select: {
+ id: true,
+ key: true,
+ active: true,
+ integration: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ definitionId: true,
+ setupStatus: true,
+ definition: {
+ select: {
+ icon: true,
+ },
+ },
+ },
+ },
+ httpEndpoint: {
+ select: {
+ key: true,
+ },
+ },
+ createdAt: true,
+ updatedAt: true,
+ params: true,
+ },
+ where: {
+ id: webhookId,
+ },
+ });
+
+ if (!webhook) {
+ throw new Error("Webhook source not found");
+ }
+
+ const deliveryListPresenter = new WebhookDeliveryListPresenter(this.#prismaClient);
+
+ const orgRootPath = organizationPath({ slug: organizationSlug });
+ const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
+
+ const requestDeliveries = await deliveryListPresenter.call({
+ userId,
+ webhookId: webhook.id,
+ direction,
+ cursor,
+ });
+
+ return {
+ webhook: {
+ id: webhook.id,
+ key: webhook.key,
+ active: webhook.active,
+ integration: webhook.integration,
+ integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
+ httpEndpoint: webhook.httpEndpoint,
+ httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
+ createdAt: webhook.createdAt,
+ updatedAt: webhook.updatedAt,
+ params: webhook.params,
+ requestDeliveries,
+ },
+ };
+ }
+}
diff --git a/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts
new file mode 100644
index 000000000..bc8244905
--- /dev/null
+++ b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts
@@ -0,0 +1,106 @@
+import { User, Webhook } from "@trigger.dev/database";
+import { PrismaClient, prisma } from "~/db.server";
+import { Organization } from "~/models/organization.server";
+import { Project } from "~/models/project.server";
+import { Direction, RunListPresenter } from "./RunListPresenter.server";
+import { organizationPath, projectPath } from "~/utils/pathBuilder";
+
+export class WebhookSourcePresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ userId,
+ projectSlug,
+ organizationSlug,
+ webhookId,
+ direction = "forward",
+ cursor,
+ getDeliveryRuns = false,
+ }: {
+ userId: User["id"];
+ projectSlug: Project["slug"];
+ organizationSlug: Organization["slug"];
+ webhookId: Webhook["id"];
+ direction?: Direction;
+ cursor?: string;
+ getDeliveryRuns?: boolean;
+ }) {
+ const webhook = await this.#prismaClient.webhook.findUnique({
+ select: {
+ id: true,
+ key: true,
+ active: true,
+ integration: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ definitionId: true,
+ setupStatus: true,
+ definition: {
+ select: {
+ icon: true,
+ },
+ },
+ },
+ },
+ httpEndpoint: {
+ select: {
+ key: true,
+ },
+ },
+ createdAt: true,
+ updatedAt: true,
+ params: true,
+ },
+ where: {
+ id: webhookId,
+ },
+ });
+
+ if (!webhook) {
+ throw new Error("Webhook source not found");
+ }
+
+ const runListPresenter = new RunListPresenter(this.#prismaClient);
+ const jobSlug = getDeliveryRuns
+ ? getDeliveryJobSlug(webhook.key)
+ : getRegistrationJobSlug(webhook.key);
+
+ const runList = await runListPresenter.call({
+ userId,
+ jobSlug,
+ organizationSlug,
+ projectSlug,
+ direction,
+ cursor,
+ });
+
+ const orgRootPath = organizationPath({ slug: organizationSlug });
+ const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
+
+ return {
+ trigger: {
+ id: webhook.id,
+ key: webhook.key,
+ active: webhook.active,
+ integration: webhook.integration,
+ integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
+ httpEndpoint: webhook.httpEndpoint,
+ httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
+ createdAt: webhook.createdAt,
+ updatedAt: webhook.updatedAt,
+ params: webhook.params,
+ runList,
+ },
+ };
+ }
+}
+
+const getRegistrationJobSlug = (key: string) => `webhook.register.${key}`;
+
+const getDeliveryJobSlug = (key: string) => `webhook.deliver.${key}`;
diff --git a/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts
new file mode 100644
index 000000000..a9e76cae5
--- /dev/null
+++ b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts
@@ -0,0 +1,71 @@
+import { Organization, User } from "@trigger.dev/database";
+import { PrismaClient, prisma } from "~/db.server";
+import { Project } from "~/models/project.server";
+
+export class WebhookTriggersPresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ userId,
+ projectSlug,
+ organizationSlug,
+ }: {
+ userId: User["id"];
+ projectSlug: Project["slug"];
+ organizationSlug: Organization["slug"];
+ }) {
+ const webhooks = await this.#prismaClient.webhook.findMany({
+ select: {
+ id: true,
+ key: true,
+ active: true,
+ params: true,
+ integration: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ definitionId: true,
+ setupStatus: true,
+ definition: {
+ select: {
+ icon: true,
+ },
+ },
+ },
+ },
+ webhookEnvironments: {
+ select: {
+ id: true,
+ environment: {
+ select: {
+ type: true
+ }
+ }
+ }
+ },
+ createdAt: true,
+ updatedAt: true,
+ },
+ where: {
+ project: {
+ slug: projectSlug,
+ organization: {
+ slug: organizationSlug,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return { webhooks };
+ }
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx
index e622d02c1..a3b1fcc0b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx
@@ -113,7 +113,7 @@ export default function Integrations() {
/>
-
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx
index 595687d17..4a719b382 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx
@@ -14,6 +14,9 @@ import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import {
PageButtons,
PageHeader,
+ PageInfoGroup,
+ PageInfoProperty,
+ PageInfoRow,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
@@ -38,13 +41,15 @@ import { HttpEndpointParamSchema, docsPath, projectHttpEndpointsPath } from "~/u
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
- const { projectParam, httpEndpointParam } = HttpEndpointParamSchema.parse(params);
+ const { projectParam, organizationSlug, httpEndpointParam } =
+ HttpEndpointParamSchema.parse(params);
const presenter = new HttpEndpointPresenter();
try {
const result = await presenter.call({
userId,
projectSlug: projectParam,
+ organizationSlug,
httpEndpointKey: httpEndpointParam,
});
@@ -98,6 +103,17 @@ export default function Page() {
+ {httpEndpoint.webhook && (
+
+
+
+
+
+ )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
index 3801568a5..0992803bc 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
@@ -1,9 +1,16 @@
import { useLocation } from "@remix-run/react";
import { LinkButton } from "~/components/primitives/Buttons";
import { Direction, RunList } from "~/presenters/RunListPresenter.server";
+import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
import { cn } from "~/utils/cn";
-export function ListPagination({ list, className }: { list: RunList; className?: string }) {
+export function ListPagination({
+ list,
+ className,
+}: {
+ list: RunList | WebhookDeliveryList;
+ className?: string;
+}) {
return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx
index dffd6cd79..4c999c639 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx
@@ -154,6 +154,7 @@ export default function Job() {
)}
{
+ const user = await requireUser(request);
+ const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
+
+ const presenter = new WebhookTriggersPresenter();
+ const data = await presenter.call({
+ userId: user.id,
+ organizationSlug,
+ projectSlug: projectParam,
+ });
+
+ return typedjson(data);
+};
+
+export const handle: Handle = {
+ breadcrumb: (match) => (
+
+ ),
+};
+
+export default function Integrations() {
+ const { webhooks } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+
+ return (
+ <>
+
+ A Webhook Trigger runs a Job when it receives a matching payload at a registered HTTP Endpoint.
+
+
+
+
+
+ Key
+ Integration
+ Properties
+ Environment
+ Active
+ Go to page
+
+
+
+ {webhooks.length > 0 ? (
+ webhooks.map((w) => {
+ const path = webhookTriggerPath(organization, project, w);
+ return (
+
+ {w.key}
+
+
+
+
+
+
+
+ {w.params && (
+
+ {Object.entries(w.params).map(([label, value], index) => (
+
+ ))}
+
+ }
+ content={
+
+ {Object.entries(w.params).map(([label, value], index) => (
+
+ ))}
+
+ }
+ />
+ )}
+
+
+
+ {w.webhookEnvironments.map((env) => (
+
+ ))}
+
+
+
+ {w.active ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+ })
+ ) : (
+
+ No External triggers
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx
index 7830ff1e4..7cb142564 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx
@@ -17,6 +17,7 @@ import {
docsPath,
projectScheduledTriggersPath,
projectTriggersPath,
+ projectWebhookTriggersPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
@@ -45,6 +46,7 @@ export default function Page() {
A Trigger is what starts a Job Run.
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx
new file mode 100644
index 000000000..d3af8ec23
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx
@@ -0,0 +1,181 @@
+import { json } from "@remix-run/node";
+import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Fragment } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
+import { Callout, variantClasses } from "~/components/primitives/Callout";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { RunsTable } from "~/components/runs/RunsTable";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useTypedMatchData } from "~/hooks/useTypedMatchData";
+import { requireUser, requireUserId } from "~/services/session.server";
+import { Handle } from "~/utils/handle";
+import {
+ TriggerSourceParamSchema,
+ projectTriggersPath,
+ externalTriggerPath,
+ trimTrailingSlash,
+ webhookTriggerRunsParentPath,
+ projectWebhookTriggersPath,
+} from "~/utils/pathBuilder";
+import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
+import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
+import { Button } from "~/components/primitives/Buttons";
+import { Form, useActionData, useNavigation } from "@remix-run/react";
+import { cn } from "~/utils/cn";
+import { conform, useForm } from "@conform-to/react";
+import { parse } from "@conform-to/zod";
+import { z } from "zod";
+import { ActivateSourceService } from "~/services/sources/activateSource.server";
+import { redirectWithSuccessMessage } from "~/models/message.server";
+import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
+
+ const url = new URL(request.url);
+ const s = Object.fromEntries(url.searchParams.entries());
+ const searchParams = RunListSearchSchema.parse(s);
+
+ const presenter = new WebhookSourcePresenter();
+ const { trigger } = await presenter.call({
+ userId: user.id,
+ organizationSlug,
+ projectSlug: projectParam,
+ webhookId: triggerParam,
+ direction: searchParams.direction,
+ cursor: searchParams.cursor,
+ });
+
+ if (!trigger) {
+ throw new Response("Trigger not found", {
+ status: 404,
+ statusText: "Not Found",
+ });
+ }
+
+ return typedjson({ trigger });
+};
+
+const schema = z.object({
+ jobId: z.string(),
+});
+
+/* export const action: ActionFunction = async ({ request, params }) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
+
+ const formData = await request.formData();
+ const submission = parse(formData, { schema });
+
+ if (!submission.value) {
+ return json(submission);
+ }
+
+ try {
+ const service = new ActivateSourceService();
+
+ const result = await service.call(triggerParam);
+
+ return redirectWithSuccessMessage(
+ externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }),
+ request,
+ `Retrying registration now`
+ );
+ } catch (error: any) {
+ return json({ errors: { body: error.message } }, { status: 400 });
+ }
+}; */
+
+export const handle: Handle = {
+ //this one is complicated because we render outside the parent route (using triggers_ in the path)
+ breadcrumb: (match, matches) => {
+ const data = useTypedMatchData(match);
+ if (!data) return null;
+
+ const org = useOrganization(matches);
+ const project = useProject(matches);
+
+ return (
+
+
+
+
+
+
+
+ );
+ },
+};
+
+export default function Page() {
+ const { trigger } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+ const navigation = useNavigation();
+ const lastSubmission = useActionData();
+
+ const [form, { jobId }] = useForm({
+ id: "trigger-registration-retry",
+ // TODO: type this
+ lastSubmission: lastSubmission as any,
+ onValidate({ formData }) {
+ return parse(formData, { schema });
+ },
+ });
+
+ const isLoading = navigation.state === "submitting" && navigation.formData !== undefined;
+
+ return (
+ <>
+
+ Webhook Triggers need to be registered with the external service. You can see the list
+ of attempted registrations below.
+
+
+ {!trigger.active &&
+ }
+
+ {trigger.runList ? (
+ <>
+
+
+
+ >
+ ) : (
+ No registration runs found
+ )}
+ >
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx
new file mode 100644
index 000000000..d30726215
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx
@@ -0,0 +1,108 @@
+import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Fragment } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
+import { Callout } from "~/components/primitives/Callout";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useTypedMatchData } from "~/hooks/useTypedMatchData";
+import { requireUser } from "~/services/session.server";
+import { Handle } from "~/utils/handle";
+import {
+ TriggerSourceParamSchema,
+ projectTriggersPath,
+ projectWebhookTriggersPath,
+ trimTrailingSlash,
+ webhookTriggerDeliveryRunsParentPath,
+ webhookTriggerPath,
+} from "~/utils/pathBuilder";
+import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
+import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
+import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
+import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
+
+ const url = new URL(request.url);
+ const s = Object.fromEntries(url.searchParams.entries());
+ const searchParams = RunListSearchSchema.parse(s);
+
+ const presenter = new WebhookDeliveryPresenter();
+ const { webhook } = await presenter.call({
+ userId: user.id,
+ organizationSlug,
+ projectSlug: projectParam,
+ webhookId: triggerParam,
+ direction: searchParams.direction,
+ cursor: searchParams.cursor,
+ });
+
+ if (!webhook) {
+ throw new Response("Trigger not found", {
+ status: 404,
+ statusText: "Not Found",
+ });
+ }
+
+ return typedjson({ webhook });
+};
+
+export const handle: Handle = {
+ //this one is complicated because we render outside the parent route (using triggers_ in the path)
+ breadcrumb: (match, matches) => {
+ const data = useTypedMatchData(match);
+ if (!data) return null;
+
+ const org = useOrganization(matches);
+ const project = useProject(matches);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ },
+};
+
+export default function Page() {
+ const { webhook } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+
+ return (
+ <>
+
+ Webhook payloads are delivered to clients for validation and event generation. You can see
+ the list of attempted deliveries below.
+
+
+ {webhook.requestDeliveries ? (
+ <>
+
+
+
+ >
+ ) : (
+ No registration runs found
+ )}
+ >
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx
new file mode 100644
index 000000000..66aee145b
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx
@@ -0,0 +1,109 @@
+import { Outlet } from "@remix-run/react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { NamedIcon } from "~/components/primitives/NamedIcon";
+import {
+ PageHeader,
+ PageInfoGroup,
+ PageInfoProperty,
+ PageInfoRow,
+ PageTabs,
+ PageTitle,
+ PageTitleRow,
+} from "~/components/primitives/PageHeader";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { requireUser } from "~/services/session.server";
+import {
+ TriggerSourceParamSchema,
+ projectWebhookTriggersPath,
+ webhookDeliveryPath,
+ webhookTriggerPath,
+} from "~/utils/pathBuilder";
+import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
+import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
+
+ const url = new URL(request.url);
+ const s = Object.fromEntries(url.searchParams.entries());
+ const searchParams = RunListSearchSchema.parse(s);
+
+ const presenter = new WebhookSourcePresenter();
+ const { trigger } = await presenter.call({
+ userId: user.id,
+ organizationSlug,
+ projectSlug: projectParam,
+ webhookId: triggerParam,
+ direction: searchParams.direction,
+ cursor: searchParams.cursor,
+ });
+
+ if (!trigger) {
+ throw new Response("Trigger not found", {
+ status: 404,
+ statusText: "Not Found",
+ });
+ }
+
+ return typedjson({ trigger });
+};
+
+export default function Page() {
+ const { trigger } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx
new file mode 100644
index 000000000..6a7574a85
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx
@@ -0,0 +1,20 @@
+import { useTypedRouteLoaderData } from "remix-typedjson";
+import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
+import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route";
+
+function useTriggerRegisterRun() {
+ const routeMatch = useTypedRouteLoaderData(
+ "routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam"
+ );
+
+ if (!routeMatch || !routeMatch.run) {
+ throw new Error("No run found");
+ }
+
+ return routeMatch.run;
+}
+
+export default function RunCompletedPage() {
+ const run = useTriggerRegisterRun();
+ return ;
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx
new file mode 100644
index 000000000..287ba86a4
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx
@@ -0,0 +1,13 @@
+import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
+import { requireUserId } from "~/services/session.server";
+
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ await requireUserId(request);
+
+ const { runParam } = z.object({ runParam: z.string() }).parse(params);
+
+ const presenter = new RunStreamPresenter();
+ return presenter.call({ request, runId: runParam });
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx
new file mode 100644
index 000000000..1e07e6deb
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx
@@ -0,0 +1,35 @@
+import { Await, useLoaderData } from "@remix-run/react";
+import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
+import { Suspense } from "react";
+import { Spinner } from "~/components/primitives/Spinner";
+import { TaskDetail } from "~/components/run/TaskDetail";
+import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
+
+ const presenter = new TaskDetailsPresenter();
+ const taskPromise = presenter.call({
+ userId,
+ id: taskParam,
+ });
+
+ return defer({
+ taskPromise,
+ });
+};
+
+export default function Page() {
+ const { taskPromise } = useLoaderData();
+
+ return (
+ }>
+ Error loading task!}>
+ {(resolvedTask) => resolvedTask && }
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx
new file mode 100644
index 000000000..e186e2ba0
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx
@@ -0,0 +1,34 @@
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { TriggerDetail } from "~/components/run/TriggerDetail";
+import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
+import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const { runParam } = TriggerSourceRunParamsSchema.parse(params);
+
+ const presenter = new TriggerDetailsPresenter();
+ const trigger = await presenter.call(runParam);
+
+ if (!trigger) {
+ throw new Response(null, {
+ status: 404,
+ });
+ }
+
+ return typedjson({
+ trigger,
+ });
+};
+
+export default function Page() {
+ const { trigger } = useTypedLoaderData();
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx
new file mode 100644
index 000000000..facd0ec4e
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx
@@ -0,0 +1,135 @@
+import { useRevalidator } from "@remix-run/react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Fragment, useEffect } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { useEventSource } from "remix-utils/sse/react";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
+import { RunOverview } from "~/components/run/RunOverview";
+import { prisma } from "~/db.server";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useTypedMatchData } from "~/hooks/useTypedMatchData";
+import { RunPresenter } from "~/presenters/RunPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { Handle } from "~/utils/handle";
+import {
+ TriggerSourceRunParamsSchema,
+ projectWebhookTriggersPath,
+ trimTrailingSlash,
+ webhookTriggerPath,
+ webhookTriggerRunPath,
+ webhookTriggerRunStreamingPath,
+ webhookTriggerRunsParentPath,
+} from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
+
+ const presenter = new RunPresenter();
+ const run = await presenter.call({
+ userId,
+ id: runParam,
+ });
+
+ const trigger = await prisma.webhook.findUnique({
+ select: {
+ id: true,
+ key: true,
+ integration: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ definitionId: true,
+ setupStatus: true,
+ },
+ },
+ },
+ where: {
+ id: triggerParam,
+ },
+ });
+
+ if (!run || !trigger) {
+ throw new Response(null, {
+ status: 404,
+ });
+ }
+
+ return typedjson({
+ run,
+ trigger,
+ });
+};
+
+export const handle: Handle = {
+ breadcrumb: (match, matches) => {
+ const data = useTypedMatchData(match);
+ if (!data) return null;
+
+ const org = useOrganization(matches);
+ const project = useProject(matches);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {data && data.run && (
+
+ )}
+
+ );
+ },
+};
+
+export default function Page() {
+ const { run, trigger } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+
+ const revalidator = useRevalidator();
+ const events = useEventSource(
+ webhookTriggerRunStreamingPath(organization, project, trigger, run),
+ {
+ event: "message",
+ }
+ );
+ useEffect(() => {
+ if (events !== null) {
+ revalidator.revalidate();
+ }
+ // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
+ }, [events]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx
new file mode 100644
index 000000000..dd70b1b88
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx
@@ -0,0 +1,20 @@
+import { useTypedRouteLoaderData } from "remix-typedjson";
+import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
+import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route";
+
+function useTriggerRegisterRun() {
+ const routeMatch = useTypedRouteLoaderData(
+ "routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam"
+ );
+
+ if (!routeMatch || !routeMatch.run) {
+ throw new Error("No run found");
+ }
+
+ return routeMatch.run;
+}
+
+export default function RunCompletedPage() {
+ const run = useTriggerRegisterRun();
+ return ;
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx
new file mode 100644
index 000000000..287ba86a4
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx
@@ -0,0 +1,13 @@
+import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
+import { requireUserId } from "~/services/session.server";
+
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ await requireUserId(request);
+
+ const { runParam } = z.object({ runParam: z.string() }).parse(params);
+
+ const presenter = new RunStreamPresenter();
+ return presenter.call({ request, runId: runParam });
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx
new file mode 100644
index 000000000..1e07e6deb
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx
@@ -0,0 +1,35 @@
+import { Await, useLoaderData } from "@remix-run/react";
+import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
+import { Suspense } from "react";
+import { Spinner } from "~/components/primitives/Spinner";
+import { TaskDetail } from "~/components/run/TaskDetail";
+import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
+
+ const presenter = new TaskDetailsPresenter();
+ const taskPromise = presenter.call({
+ userId,
+ id: taskParam,
+ });
+
+ return defer({
+ taskPromise,
+ });
+};
+
+export default function Page() {
+ const { taskPromise } = useLoaderData();
+
+ return (
+ }>
+ Error loading task!}>
+ {(resolvedTask) => resolvedTask && }
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx
new file mode 100644
index 000000000..478dfbe77
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx
@@ -0,0 +1,34 @@
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { TriggerDetail } from "~/components/run/TriggerDetail";
+import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
+import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const { runParam } = TriggerSourceRunParamsSchema.parse(params);
+
+ const presenter = new TriggerDetailsPresenter();
+ const trigger = await presenter.call(runParam);
+
+ if (!trigger) {
+ throw new Response(null, {
+ status: 404,
+ });
+ }
+
+ return typedjson({
+ trigger,
+ });
+};
+
+export default function Page() {
+ const { trigger } = useTypedLoaderData();
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx
new file mode 100644
index 000000000..f7fc7eb0f
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx
@@ -0,0 +1,132 @@
+import { useRevalidator } from "@remix-run/react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Fragment, useEffect } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { useEventSource } from "remix-utils/sse/react";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
+import { RunOverview } from "~/components/run/RunOverview";
+import { prisma } from "~/db.server";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useTypedMatchData } from "~/hooks/useTypedMatchData";
+import { RunPresenter } from "~/presenters/RunPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { Handle } from "~/utils/handle";
+import {
+ TriggerSourceRunParamsSchema,
+ projectWebhookTriggersPath,
+ trimTrailingSlash,
+ webhookDeliveryPath,
+ webhookTriggerDeliveryRunPath,
+ webhookTriggerDeliveryRunsParentPath,
+ webhookTriggerPath,
+ webhookTriggerRunStreamingPath,
+} from "~/utils/pathBuilder";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
+
+ const presenter = new RunPresenter();
+ const run = await presenter.call({
+ userId,
+ id: runParam,
+ });
+
+ const trigger = await prisma.webhook.findUnique({
+ select: {
+ id: true,
+ integration: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ definitionId: true,
+ setupStatus: true,
+ },
+ },
+ },
+ where: {
+ id: triggerParam,
+ },
+ });
+
+ if (!run || !trigger) {
+ throw new Response(null, {
+ status: 404,
+ });
+ }
+
+ return typedjson({
+ run,
+ trigger,
+ });
+};
+
+export const handle: Handle = {
+ breadcrumb: (match, matches) => {
+ const data = useTypedMatchData(match);
+ if (!data) return null;
+
+ const org = useOrganization(matches);
+ const project = useProject(matches);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {data && data.run && (
+
+ )}
+
+ );
+ },
+};
+
+export default function Page() {
+ const { run, trigger } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+
+ const revalidator = useRevalidator();
+ const events = useEventSource(
+ webhookTriggerRunStreamingPath(organization, project, trigger, run),
+ {
+ event: "message",
+ }
+ );
+ useEffect(() => {
+ if (events !== null) {
+ revalidator.revalidate();
+ }
+ // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
+ }, [events]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/api.v1.store.$key.ts b/apps/webapp/app/routes/api.v1.store.$key.ts
new file mode 100644
index 000000000..2350cf029
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.store.$key.ts
@@ -0,0 +1,156 @@
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { assertExhaustive } from "@trigger.dev/core";
+import { z } from "zod";
+import { authenticateApiRequest } from "~/services/apiAuth.server";
+import { logger } from "~/services/logger.server";
+import { KeyValueStore } from "~/services/store/keyValueStore.server";
+
+const ParamsSchema = z.object({
+ key: z.string(),
+});
+
+const MAX_BODY_BYTE_LENGTH = 256 * 1024;
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ logger.info("Key-value store action", { url: request.url });
+
+ const ActionMethodSchema = z.enum(["DELETE", "PUT"]);
+
+ const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
+
+ if (!parsedMethod.success) {
+ return json({ error: "Method Not Allowed" }, { status: 405 });
+ }
+
+ const parsedParams = ParamsSchema.safeParse(params);
+
+ if (!parsedParams.success) {
+ logger.info("Invalid params", { params });
+
+ return json({ error: "Invalid params" }, { status: 400 });
+ }
+
+ const authenticationResult = await authenticateApiRequest(request);
+
+ if (!authenticationResult) {
+ logger.info("Invalid or missing api key", { url: request.url });
+ return json({ error: "Invalid or Missing API key" }, { status: 401 });
+ }
+
+ const authenticatedEnv = authenticationResult.environment;
+
+ const store = new KeyValueStore(authenticatedEnv);
+
+ const { key } = parsedParams.data;
+
+ try {
+ switch (parsedMethod.data) {
+ case "DELETE": {
+ const deleted = await store.delete(key);
+
+ return json({ action: "DELETE", key, deleted });
+ }
+ case "PUT": {
+ const value = await request.text();
+
+ const serializedValueBytes = value.length;
+
+ if (serializedValueBytes > MAX_BODY_BYTE_LENGTH) {
+ logger.info("Max request body size exceeded", { serializedValueBytes });
+
+ return json(
+ { error: `Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes` },
+ { status: 413 }
+ );
+ }
+
+ const setValue = await store.set(key, value);
+
+ return json({ action: "SET", key, value: setValue });
+ }
+ default: {
+ assertExhaustive(parsedMethod.data);
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error("Error peforming key-value store action", {
+ method: parsedMethod.data,
+ url: request.url,
+ error: error.message,
+ });
+
+ return json({ error: error.message }, { status: 400 });
+ }
+
+ return json({ error: "Something went wrong" }, { status: 500 });
+ }
+}
+
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ logger.info("Key-value store loader", { url: request.url });
+
+ const parsedParams = ParamsSchema.safeParse(params);
+
+ if (!parsedParams.success) {
+ logger.info("Invalid params", { params });
+
+ return json({ error: "Invalid params" }, { status: 400 });
+ }
+
+ const ActionMethodSchema = z.enum(["GET", "HEAD"]);
+
+ const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
+
+ if (!parsedMethod.success) {
+ return json({ error: "Method Not Allowed" }, { status: 405 });
+ }
+
+ const authenticationResult = await authenticateApiRequest(request);
+
+ if (!authenticationResult) {
+ logger.info("Invalid or missing api key", { url: request.url });
+ return json({ error: "Invalid or Missing API key" }, { status: 401 });
+ }
+
+ const authenticatedEnv = authenticationResult.environment;
+
+ const store = new KeyValueStore(authenticatedEnv);
+
+ const { key } = parsedParams.data;
+
+ try {
+ switch (parsedMethod.data) {
+ case "GET": {
+ const value = await store.get(key);
+
+ return json({ action: "GET", key, value });
+ }
+ case "HEAD": {
+ const has = await store.has(key);
+
+ if (!has) {
+ return new Response("Key not found", { status: 404 });
+ }
+
+ return new Response("Key found", { status: 200 });
+ }
+ default: {
+ assertExhaustive(parsedMethod.data);
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error("Error peforming key-value store action", {
+ method: parsedMethod.data,
+ url: request.url,
+ error: error.message,
+ });
+
+ return json({ error: error.message }, { status: 400 });
+ }
+
+ return json({ error: "Something went wrong" }, { status: 500 });
+ }
+}
diff --git a/apps/webapp/app/routes/api.v1.webhooks.$key.ts b/apps/webapp/app/routes/api.v1.webhooks.$key.ts
new file mode 100644
index 000000000..572898aaa
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.webhooks.$key.ts
@@ -0,0 +1,70 @@
+import type { ActionFunctionArgs } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { UpdateWebhookBodySchema } from "@trigger.dev/core";
+import { z } from "zod";
+import { authenticateApiRequest } from "~/services/apiAuth.server";
+import { logger } from "~/services/logger.server";
+import { UpdateWebhookService } from "~/services/sources/updateWebhook.server";
+
+const ParamsSchema = z.object({
+ key: z.string(),
+});
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ logger.info("Updating webhook", { url: request.url });
+
+ // Ensure this is a POST request
+ if (request.method.toUpperCase() !== "PUT") {
+ return { status: 405, body: "Method Not Allowed" };
+ }
+
+ const parsedParams = ParamsSchema.safeParse(params);
+
+ if (!parsedParams.success) {
+ logger.info("Invalid params", { params });
+
+ return json({ error: "Invalid params" }, { status: 400 });
+ }
+
+ // Next authenticate the request
+ const authenticationResult = await authenticateApiRequest(request);
+
+ if (!authenticationResult) {
+ logger.info("Invalid or missing api key", { url: request.url });
+ return json({ error: "Invalid or Missing API key" }, { status: 401 });
+ }
+
+ const authenticatedEnv = authenticationResult.environment;
+
+ // Now parse the request body
+ const anyBody = await request.json();
+
+ const body = UpdateWebhookBodySchema.safeParse(anyBody);
+
+ if (!body.success) {
+ return json({ error: "Invalid request body" }, { status: 400 });
+ }
+
+ const service = new UpdateWebhookService();
+
+ try {
+ const source = await service.call({
+ environment: authenticatedEnv,
+ payload: body.data,
+ key: parsedParams.data.key,
+ });
+
+ return json(source);
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error("Error updating webhook", {
+ url: request.url,
+ error: error.message,
+ });
+
+ return json({ error: error.message }, { status: 400 });
+ }
+
+ return json({ error: "Something went wrong" }, { status: 500 });
+ }
+}
diff --git a/apps/webapp/app/services/endpointApi.server.ts b/apps/webapp/app/services/endpointApi.server.ts
index 33967d374..1f64fbbdc 100644
--- a/apps/webapp/app/services/endpointApi.server.ts
+++ b/apps/webapp/app/services/endpointApi.server.ts
@@ -18,6 +18,7 @@ import {
RunNotification,
ValidateResponse,
ValidateResponseSchema,
+ WebhookDeliveryResponseSchema,
} from "@trigger.dev/core";
import { performance } from "node:perf_hooks";
import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json";
@@ -253,6 +254,45 @@ export class EndpointApi {
return HttpSourceResponseSchema.parse(anyBody);
}
+ async deliverWebhookRequest(options: {
+ key: string;
+ secret: string;
+ params: any;
+ request: HttpSourceRequest;
+ }) {
+ const response = await safeFetch(this.url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "x-trigger-api-key": this.apiKey,
+ "x-trigger-action": "DELIVER_WEBHOOK_REQUEST",
+ "x-ts-key": options.key,
+ "x-ts-secret": options.secret,
+ "x-ts-params": JSON.stringify(options.params ?? {}),
+ "x-ts-http-url": options.request.url,
+ "x-ts-http-method": options.request.method,
+ "x-ts-http-headers": JSON.stringify(options.request.headers),
+ },
+ body: options.request.rawBody,
+ });
+
+ if (!response) {
+ throw new Error(`Could not connect to endpoint ${this.url}`);
+ }
+
+ if (!response.ok) {
+ throw new Error(`Could not connect to endpoint ${this.url}. Status code: ${response.status}`);
+ }
+
+ const anyBody = await response.json();
+
+ logger.debug("deliverWebhookRequest() response from endpoint", {
+ body: anyBody,
+ });
+
+ return WebhookDeliveryResponseSchema.parse(anyBody);
+ }
+
async deliverHttpEndpointRequestForResponse(options: {
key: string;
secret: string;
diff --git a/apps/webapp/app/services/endpoints/performEndpointIndexService.ts b/apps/webapp/app/services/endpoints/performEndpointIndexService.ts
index 775625eb8..6c31280d7 100644
--- a/apps/webapp/app/services/endpoints/performEndpointIndexService.ts
+++ b/apps/webapp/app/services/endpoints/performEndpointIndexService.ts
@@ -1,6 +1,4 @@
-import type { EndpointIndexSource } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
-import { findEndpoint } from "~/models/endpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RegisterJobService } from "../jobs/registerJob.server";
import { logger } from "../logger.server";
@@ -14,6 +12,7 @@ import { safeBodyFromResponse } from "~/utils/json";
import { fromZodError } from "zod-validation-error";
import { IndexEndpointStats } from "@trigger.dev/core";
import { RegisterHttpEndpointService } from "../triggers/registerHttpEndpoint.server";
+import { RegisterWebhookService } from "../triggers/registerWebhook.server";
export class PerformEndpointIndexService {
#prismaClient: PrismaClient;
@@ -24,6 +23,7 @@ export class PerformEndpointIndexService {
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
#registerHttpEndpointService = new RegisterHttpEndpointService();
+ #registerWebhookService = new RegisterWebhookService();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
@@ -128,7 +128,8 @@ export class PerformEndpointIndexService {
});
}
- const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints } = bodyResult.data;
+ const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints, webhooks } =
+ bodyResult.data;
const { "trigger-version": triggerVersion, "trigger-sdk-version": triggerSdkVersion } =
headerResult.data;
const { endpoint } = endpointIndex;
@@ -151,6 +152,7 @@ export class PerformEndpointIndexService {
const indexStats: IndexEndpointStats = {
jobs: 0,
sources: 0,
+ webhooks: 0,
dynamicTriggers: 0,
dynamicSchedules: 0,
disabledJobs: 0,
@@ -318,6 +320,21 @@ export class PerformEndpointIndexService {
}
}
+ if (webhooks) {
+ for (const webhook of webhooks) {
+ try {
+ await this.#registerWebhookService.call(endpoint, webhook);
+ indexStats.webhooks++;
+ } catch (error) {
+ logger.error("Failed to register webhook", {
+ endpointId: endpoint.id,
+ webhook,
+ error,
+ });
+ }
+ }
+ }
+
logger.debug("Endpoint indexing complete", {
endpointId: endpoint.id,
indexStats,
@@ -336,6 +353,7 @@ export class PerformEndpointIndexService {
data: {
jobs,
sources,
+ webhooks,
dynamicTriggers,
dynamicSchedules,
httpEndpoints,
diff --git a/apps/webapp/app/services/externalApis/integrationCatalog.server.ts b/apps/webapp/app/services/externalApis/integrationCatalog.server.ts
index 3c13f154a..0ec27874e 100644
--- a/apps/webapp/app/services/externalApis/integrationCatalog.server.ts
+++ b/apps/webapp/app/services/externalApis/integrationCatalog.server.ts
@@ -6,6 +6,7 @@ import { plain } from "./integrations/plain";
import { replicate } from "./integrations/replicate";
import { resend } from "./integrations/resend";
import { sendgrid } from "./integrations/sendgrid";
+import { shopify } from "./integrations/shopify";
import { slack } from "./integrations/slack";
import { stripe } from "./integrations/stripe";
import { supabase, supabaseManagement } from "./integrations/supabase";
@@ -40,6 +41,7 @@ export const integrationCatalog = new IntegrationCatalog({
plain,
replicate,
resend,
+ shopify,
slack,
stripe,
supabaseManagement,
diff --git a/apps/webapp/app/services/externalApis/integrations/linear.ts b/apps/webapp/app/services/externalApis/integrations/linear.ts
index bb1266b2b..841ded653 100644
--- a/apps/webapp/app/services/externalApis/integrations/linear.ts
+++ b/apps/webapp/app/services/externalApis/integrations/linear.ts
@@ -7,7 +7,7 @@ function usageSample(hasApiKey: boolean): HelpSample {
import { Linear } from "@trigger.dev/linear";
const linear = new Linear({
- id: "__SLUG__",${hasApiKey ? ",\n apiKey: process.env.LINEAR_API_KEY!" : ""}
+ id: "__SLUG__",${hasApiKey ? "\n apiKey: process.env.LINEAR_API_KEY!," : ""}
});
client.defineJob({
diff --git a/apps/webapp/app/services/externalApis/integrations/replicate.ts b/apps/webapp/app/services/externalApis/integrations/replicate.ts
index 74f20cdaf..095a043e5 100644
--- a/apps/webapp/app/services/externalApis/integrations/replicate.ts
+++ b/apps/webapp/app/services/externalApis/integrations/replicate.ts
@@ -9,7 +9,7 @@ function usageSample(hasApiKey: boolean): HelpSample {
import { Replicate } from "@trigger.dev/replicate";
const replicate = new Replicate({
- id: "__SLUG__",${hasApiKey ? `,\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!` : ""}
+ id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!,` : ""}
});
client.defineJob({
diff --git a/apps/webapp/app/services/externalApis/integrations/shopify.ts b/apps/webapp/app/services/externalApis/integrations/shopify.ts
new file mode 100644
index 000000000..07c4408ec
--- /dev/null
+++ b/apps/webapp/app/services/externalApis/integrations/shopify.ts
@@ -0,0 +1,55 @@
+import type { HelpSample, Integration } from "../types";
+
+function usageSample(hasApiKey: boolean): HelpSample {
+ const apiKeyPropertyName = "apiKey";
+
+ return {
+ title: "Using the client",
+ code: `
+import { Shopify } from "@trigger.dev/shopify";
+
+const shopify = new Shopify({
+ id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.SHOPIFY_API_KEY!,` : ""}
+ apiSecretKey: process.env.SHOPIFY_API_SECRET_KEY!,
+ adminAccessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
+ hostName: process.env.SHOPIFY_SHOP_DOMAIN!,
+});
+
+client.defineJob({
+ id: "shopify-create-product",
+ name: "Shopify: Create Product",
+ version: "0.1.0",
+ integrations: { shopify },
+ trigger: eventTrigger({
+ name: "shopify.product.create",
+ schema: z.object({
+ title: z.string(),
+ }),
+ }),
+ run: async (payload, io, ctx) => {
+ const product = await io.shopify.rest.Product.save("create-product", {
+ fromData: {
+ title: payload.title,
+ },
+ });
+
+ await io.logger.info(\`Created product \${product.id}: \${product.title}\`);
+ },
+});
+ `,
+ };
+}
+
+export const shopify: Integration = {
+ identifier: "shopify",
+ name: "Shopify",
+ packageName: "@trigger.dev/shopify@latest",
+ authenticationMethods: {
+ apikey: {
+ type: "apikey",
+ help: {
+ samples: [usageSample(true)],
+ },
+ },
+ },
+};
diff --git a/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts b/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts
index 7c9f196c8..0a3cab774 100644
--- a/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts
+++ b/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts
@@ -1,16 +1,21 @@
-import { PrismaClient, TriggerHttpEndpoint } from "@trigger.dev/database";
+import { PrismaClient, RuntimeEnvironment, Webhook } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { logger } from "../logger.server";
import { json } from "@remix-run/server-runtime";
-import { RequestFilterSchema, requestFilterMatches } from "@trigger.dev/core";
+import {
+ RequestFilterSchema,
+ WebhookContextMetadataSchema,
+ requestFilterMatches,
+} from "@trigger.dev/core";
import { EndpointApi } from "../endpointApi.server";
import { IngestSendEvent } from "../events/ingestSendEvent.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
import { ulid } from "../ulid.server";
import { env } from "~/env.server";
+import { HandleWebhookRequestService } from "../sources/handleWebhookRequest.server";
export const HttpEndpointParamsSchema = z.object({
httpEndpointId: z.string(),
@@ -34,6 +39,7 @@ export class HandleHttpEndpointService {
},
include: {
secretReference: true,
+ webhook: true,
project: {
include: {
environments: {
@@ -108,6 +114,7 @@ export class HandleHttpEndpointService {
//get the secret
const secretStore = getSecretStore(httpEndpoint.secretReference.provider);
let secret: string | undefined;
+
try {
const secretData = await secretStore.getSecretOrThrow(
z.object({ secret: z.string() }),
@@ -119,6 +126,7 @@ export class HandleHttpEndpointService {
logger.error("Getting secret threw", { error });
return json({ error: true, message: "Could not retrieve secret" }, { status: 404 });
}
+
if (!secret) {
logger.error("Could not find secret", {
httpEndpointId: httpEndpoint.id,
@@ -132,16 +140,22 @@ export class HandleHttpEndpointService {
const callClientImmediately = immediateResponseFilter.data
? await requestFilterMatches(request, immediateResponseFilter.data)
: false;
+
let httpResponse: Response | undefined;
+
if (callClientImmediately) {
logger.info("Calling client immediately", {
httpEndpointId: httpEndpoint.id,
environmentId: environment.id,
immediateResponseFilter: immediateResponseFilter.data,
});
+
const clonedRequest = request.clone();
+
const client = new EndpointApi(environment.apiKey, httpEndpointEnvironment.endpoint.url);
+
const httpRequest = await createHttpSourceRequest(clonedRequest);
+
const { response, parser } = await client.deliverHttpEndpointRequestForResponse({
key: httpEndpoint.key,
secret: secret,
@@ -149,7 +163,9 @@ export class HandleHttpEndpointService {
});
const responseJson = await response.json();
+
const parsedResponseResult = parser.safeParse(responseJson);
+
if (!parsedResponseResult.success) {
logger.error("Could not parse response from client", {
httpEndpointId: httpEndpoint.id,
@@ -157,6 +173,7 @@ export class HandleHttpEndpointService {
responseJson,
errors: parsedResponseResult.error,
});
+
return json(
{ error: true, message: "Could not parse response from client" },
{ status: 500 }
@@ -164,6 +181,7 @@ export class HandleHttpEndpointService {
}
const endpointResponse = parsedResponseResult.data;
+
httpResponse = new Response(endpointResponse.body, {
status: endpointResponse.status,
headers: endpointResponse.headers,
@@ -186,11 +204,17 @@ export class HandleHttpEndpointService {
return httpResponse;
}
+ if (httpEndpoint.webhook) {
+ return await this.#handleWebhookRequest(request, environment, httpEndpoint.webhook, secret);
+ }
+
const ingestService = new IngestSendEvent();
+
let rawBody: string | undefined;
try {
rawBody = await request.text();
} catch (e) {}
+
const url = requestUrl(request);
const event = {
headers: Object.fromEntries(request.headers) as Record,
@@ -223,6 +247,44 @@ export class HandleHttpEndpointService {
})
);
}
+
+ async #handleWebhookRequest(
+ request: Request,
+ environment: RuntimeEnvironment,
+ webhook: Webhook,
+ secret: string
+ ) {
+ const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({
+ where: {
+ environmentId_webhookId: {
+ environmentId: environment.id,
+ webhookId: webhook.id,
+ },
+ },
+ include: {
+ endpoint: true,
+ },
+ });
+
+ if (!webhookEnvironment) {
+ logger.debug("Could not find webhook environment", {
+ webhookId: webhook.id,
+ environmentId: environment.id,
+ });
+ return json({ error: true, message: "Could not find webhook environment" }, { status: 404 });
+ }
+
+ const rawContext = {
+ secret,
+ config: webhookEnvironment.config,
+ params: webhook.params,
+ };
+ const webhookContextMetadata = WebhookContextMetadataSchema.parse(rawContext);
+
+ const service = new HandleWebhookRequestService(this.#prismaClient);
+
+ return await service.call(webhookEnvironment.id, request, webhookContextMetadata);
+ }
}
type GetHttpEndpointUrlParams = {
diff --git a/apps/webapp/app/services/jobs/registerJob.server.ts b/apps/webapp/app/services/jobs/registerJob.server.ts
index bee4dccec..63aeb5cb5 100644
--- a/apps/webapp/app/services/jobs/registerJob.server.ts
+++ b/apps/webapp/app/services/jobs/registerJob.server.ts
@@ -3,6 +3,7 @@ import {
JobMetadata,
SCHEDULED_EVENT,
TriggerMetadata,
+ assertExhaustive,
} from "@trigger.dev/core";
import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database";
import { DEFAULT_MAX_CONCURRENT_RUNS } from "~/consts";
@@ -638,7 +639,3 @@ export class RegisterJobService {
});
}
}
-
-function assertExhaustive(x: never): never {
- throw new Error("Unexpected object: " + x);
-}
diff --git a/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts b/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts
new file mode 100644
index 000000000..f821ecce4
--- /dev/null
+++ b/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts
@@ -0,0 +1,97 @@
+import { z } from "zod";
+import type { PrismaClient } from "~/db.server";
+import { prisma } from "~/db.server";
+import { EndpointApi } from "../endpointApi.server";
+import { getSecretStore } from "../secrets/secretStore.server";
+
+export class DeliverWebhookRequestService {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call(id: string) {
+ const requestDelivery = await this.#prismaClient.webhookRequestDelivery.findUniqueOrThrow({
+ where: {
+ id,
+ },
+ include: {
+ webhook: {
+ include: {
+ integration: {
+ include: {
+ connections: true,
+ },
+ },
+ httpEndpoint: {
+ include: {
+ secretReference: true,
+ },
+ },
+ },
+ },
+ webhookEnvironment: {
+ include: {
+ environment: {
+ include: {
+ organization: true,
+ project: true,
+ },
+ },
+ },
+ },
+ endpoint: true,
+ },
+ });
+
+ if (!requestDelivery.webhookEnvironment.active) {
+ return;
+ }
+
+ const { secretReference } = requestDelivery.webhook.httpEndpoint;
+
+ const secretStore = getSecretStore(secretReference.provider);
+
+ const secret = await secretStore.getSecret(
+ z.object({
+ secret: z.string(),
+ }),
+ secretReference.key
+ );
+
+ if (!secret) {
+ throw new Error(`Secret not found for ${requestDelivery.webhook.key}`);
+ }
+
+ const clientApi = new EndpointApi(
+ requestDelivery.webhookEnvironment.environment.apiKey,
+ requestDelivery.endpoint.url
+ );
+
+ const { response, verified, error } = await clientApi.deliverWebhookRequest({
+ key: requestDelivery.webhook.key,
+ secret: secret.secret,
+ params: requestDelivery.webhook.params,
+ request: {
+ url: requestDelivery.url,
+ method: requestDelivery.method,
+ headers: requestDelivery.headers as Record,
+ rawBody: requestDelivery.body,
+ },
+ });
+
+ await this.#prismaClient.webhookRequestDelivery.update({
+ where: {
+ id,
+ },
+ data: {
+ deliveredAt: new Date(),
+ verified,
+ error,
+ },
+ });
+
+ return response;
+ }
+}
diff --git a/apps/webapp/app/services/sources/handleWebhookRequest.server.ts b/apps/webapp/app/services/sources/handleWebhookRequest.server.ts
new file mode 100644
index 000000000..d8265864e
--- /dev/null
+++ b/apps/webapp/app/services/sources/handleWebhookRequest.server.ts
@@ -0,0 +1,85 @@
+import type { PrismaClient } from "~/db.server";
+import { prisma } from "~/db.server";
+import { workerQueue } from "../worker.server";
+import { RuntimeEnvironmentType } from "@trigger.dev/database";
+import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
+import { WebhookContextMetadata } from "@trigger.dev/core";
+import { createHash } from "crypto";
+
+export class HandleWebhookRequestService {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call(id: string, request: Request, metadata: WebhookContextMetadata) {
+ const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({
+ where: {
+ id,
+ },
+ include: {
+ endpoint: true,
+ environment: true,
+ },
+ });
+
+ if (!webhookEnvironment) {
+ return { status: 404 };
+ }
+
+ if (!webhookEnvironment.active) {
+ return { status: 200 };
+ }
+
+ const webhookRequest = await createHttpSourceRequest(request);
+
+ const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
+
+ await this.#prismaClient.$transaction(async (tx) => {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
+
+ const counter = await tx.webhookDeliveryCounter.upsert({
+ where: { webhookId: webhookEnvironment.id },
+ update: { lastNumber: { increment: 1 } },
+ create: { webhookId: webhookEnvironment.id, lastNumber: 1 },
+ select: { lastNumber: true },
+ });
+
+ const delivery = await tx.webhookRequestDelivery.create({
+ data: {
+ number: counter.lastNumber,
+ webhookId: webhookEnvironment.webhookId,
+ webhookEnvironmentId: webhookEnvironment.id,
+ endpointId: webhookEnvironment.endpointId,
+ environmentId: webhookEnvironment.environmentId,
+ url: webhookRequest.url,
+ method: webhookRequest.method,
+ headers: webhookRequest.headers,
+ body: webhookRequest.rawBody,
+ },
+ });
+
+ await workerQueue.enqueue(
+ "deliverWebhookRequest",
+ {
+ id: delivery.id,
+ },
+ {
+ tx,
+ maxAttempts:
+ webhookEnvironment.environment.type === RuntimeEnvironmentType.DEVELOPMENT
+ ? 1
+ : undefined,
+ }
+ );
+ });
+
+ return { status: 200 };
+ }
+}
+
+function webhookIdToLockId(webhookId: string): number {
+ // Convert webhookId to a unique lock identifier
+ return parseInt(createHash("sha256").update(webhookId).digest("hex").slice(0, 8), 16);
+}
diff --git a/apps/webapp/app/services/sources/updateWebhook.server.ts b/apps/webapp/app/services/sources/updateWebhook.server.ts
new file mode 100644
index 000000000..ec2e04b90
--- /dev/null
+++ b/apps/webapp/app/services/sources/updateWebhook.server.ts
@@ -0,0 +1,62 @@
+import type { TriggerSource, UpdateWebhookBody } from "@trigger.dev/core";
+import type { RuntimeEnvironment } from "@trigger.dev/database";
+import type { PrismaClient } from "~/db.server";
+import { prisma } from "~/db.server";
+
+export class UpdateWebhookService {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ environment,
+ payload,
+ key,
+ }: {
+ environment: RuntimeEnvironment;
+ payload: UpdateWebhookBody;
+ key: string;
+ }): Promise {
+ const webhook = await this.#prismaClient.webhook.findUniqueOrThrow({
+ where: {
+ key_projectId: {
+ key,
+ projectId: environment.projectId,
+ },
+ },
+ });
+
+ await this.#prismaClient.webhook.update({
+ where: {
+ key_projectId: {
+ key,
+ projectId: environment.projectId,
+ },
+ },
+ data: {
+ active: payload.active,
+ webhookEnvironments: {
+ update: {
+ where: {
+ environmentId_webhookId: {
+ environmentId: environment.id,
+ webhookId: webhook.id,
+ },
+ },
+ data: {
+ active: payload.active,
+ config: payload.active ? payload.config : undefined,
+ },
+ },
+ },
+ },
+ });
+
+ return {
+ id: webhook.id,
+ key: webhook.key,
+ };
+ }
+}
diff --git a/apps/webapp/app/services/store/keyValueStore.server.ts b/apps/webapp/app/services/store/keyValueStore.server.ts
new file mode 100644
index 000000000..8f8f13d14
--- /dev/null
+++ b/apps/webapp/app/services/store/keyValueStore.server.ts
@@ -0,0 +1,96 @@
+import { RuntimeEnvironment } from "@trigger.dev/database";
+import type { AsyncMap } from "@trigger.dev/core";
+import type { PrismaClient } from "~/db.server";
+import { prisma } from "~/db.server";
+import { logger } from "../logger.server";
+
+export class KeyValueStore implements AsyncMap {
+ #prismaClient: PrismaClient;
+
+ constructor(private environment: RuntimeEnvironment, prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ async delete(key: string): Promise {
+ try {
+ await this.#prismaClient.keyValueItem.delete({
+ select: {
+ id: true,
+ },
+ where: {
+ environmentId_key: {
+ key,
+ environmentId: this.environment.id,
+ },
+ },
+ });
+
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+
+ async get(key: string): Promise {
+ const keyValueItem = await this.#prismaClient.keyValueItem.findUnique({
+ select: {
+ value: true,
+ },
+ where: {
+ environmentId_key: {
+ key,
+ environmentId: this.environment.id,
+ },
+ },
+ });
+
+ if (!keyValueItem) {
+ logger.debug("KeyValueStore.get() key not found", { key, environment: this.environment.id });
+ return undefined;
+ }
+
+ return keyValueItem.value.toString();
+ }
+
+ async has(key: string): Promise {
+ const keyValueItem = await this.#prismaClient.keyValueItem.findUnique({
+ select: {
+ id: true,
+ },
+ where: {
+ environmentId_key: {
+ key,
+ environmentId: this.environment.id,
+ },
+ },
+ });
+
+ return !!keyValueItem;
+ }
+
+ async set(key: string, value: TValue): Promise {
+ const valueBuffer = Buffer.from(value);
+
+ await this.#prismaClient.keyValueItem.upsert({
+ select: {
+ value: true,
+ },
+ where: {
+ environmentId_key: {
+ key,
+ environmentId: this.environment.id,
+ },
+ },
+ create: {
+ key,
+ environmentId: this.environment.id,
+ value: valueBuffer,
+ },
+ update: {
+ value: valueBuffer,
+ },
+ });
+
+ return value;
+ }
+}
diff --git a/apps/webapp/app/services/triggers/registerWebhook.server.ts b/apps/webapp/app/services/triggers/registerWebhook.server.ts
new file mode 100644
index 000000000..f5e004f86
--- /dev/null
+++ b/apps/webapp/app/services/triggers/registerWebhook.server.ts
@@ -0,0 +1,169 @@
+import { REGISTER_WEBHOOK, WebhookMetadata } from "@trigger.dev/core";
+import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
+import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
+import { IngestSendEvent } from "../events/ingestSendEvent.server";
+import { Prisma, WebhookEnvironment } from "@trigger.dev/database";
+import { ulid } from "../ulid.server";
+import { getSecretStore } from "../secrets/secretStore.server";
+import { z } from "zod";
+import { httpEndpointUrl } from "../httpendpoint/HandleHttpEndpointService";
+import { isEqual } from "ohash";
+
+type ExtendedWebhook = Prisma.WebhookGetPayload<{
+ include: {
+ httpEndpoint: {
+ include: {
+ secretReference: true;
+ };
+ };
+ };
+}>;
+
+export class RegisterWebhookService {
+ #prismaClient: PrismaClientOrTransaction;
+
+ constructor(prismaClient: PrismaClientOrTransaction = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call(
+ endpointIdOrEndpoint: string | ExtendedEndpoint,
+ webhookMetadata: WebhookMetadata
+ ) {
+ const endpoint =
+ typeof endpointIdOrEndpoint === "string"
+ ? await findEndpoint(endpointIdOrEndpoint)
+ : endpointIdOrEndpoint;
+
+ const upsertResult = await this.#upsertWebhook(endpoint, webhookMetadata);
+
+ if (!upsertResult) {
+ return;
+ }
+
+ const { webhook, webhookEnvironment } = upsertResult;
+ const { config, desiredConfig } = webhookEnvironment;
+
+ if (webhook.active && isEqual(config, desiredConfig)) {
+ return;
+ }
+
+ return await this.#activateWebhook(endpoint, webhook, webhookEnvironment);
+ }
+
+ async #upsertWebhook(endpoint: ExtendedEndpoint, webhookMetadata: WebhookMetadata) {
+ return await $transaction(this.#prismaClient, async (tx) => {
+ const webhook = await tx.webhook.upsert({
+ where: {
+ key_projectId: {
+ key: webhookMetadata.key,
+ projectId: endpoint.projectId,
+ },
+ },
+ create: {
+ key: webhookMetadata.key,
+ params: webhookMetadata.params,
+ httpEndpoint: {
+ connect: {
+ key_projectId: {
+ key: webhookMetadata.httpEndpoint.id,
+ projectId: endpoint.projectId,
+ },
+ },
+ },
+ project: {
+ connect: {
+ id: endpoint.projectId,
+ },
+ },
+ integration: {
+ connect: {
+ organizationId_slug: {
+ organizationId: endpoint.organizationId,
+ slug: webhookMetadata.integration.id,
+ },
+ },
+ },
+ },
+ update: {
+ key: webhookMetadata.key,
+ params: webhookMetadata.params,
+ },
+ include: {
+ httpEndpoint: {
+ include: {
+ secretReference: true,
+ },
+ },
+ },
+ });
+
+ const webhookEnvironment = await tx.webhookEnvironment.upsert({
+ where: {
+ environmentId_webhookId: {
+ environmentId: endpoint.environmentId,
+ webhookId: webhook.id,
+ },
+ },
+ create: {
+ desiredConfig: webhookMetadata.config,
+ webhook: {
+ connect: {
+ id: webhook.id,
+ },
+ },
+ environment: {
+ connect: {
+ id: endpoint.environmentId,
+ },
+ },
+ endpoint: {
+ connect: {
+ id: endpoint.id,
+ },
+ },
+ },
+ update: {
+ desiredConfig: webhookMetadata.config,
+ },
+ });
+
+ return { webhook, webhookEnvironment };
+ });
+ }
+
+ async #activateWebhook(
+ endpoint: ExtendedEndpoint,
+ webhook: ExtendedWebhook,
+ webhookEnvironment: WebhookEnvironment
+ ) {
+ const { httpEndpoint } = webhook;
+
+ const secretStore = getSecretStore(httpEndpoint.secretReference.provider);
+
+ const secretData = await secretStore.getSecretOrThrow(
+ z.object({ secret: z.string() }),
+ httpEndpoint.secretReference.key
+ );
+
+ const ingestService = new IngestSendEvent();
+
+ await ingestService.call(endpoint.environment, {
+ id: ulid(),
+ name: `${REGISTER_WEBHOOK}.${webhook.key}`,
+ payload: {
+ active: webhook.active,
+ url: httpEndpointUrl({
+ httpEndpointId: httpEndpoint.id,
+ environment: endpoint.environment,
+ }),
+ secret: secretData.secret,
+ params: webhook.params,
+ config: {
+ current: webhookEnvironment.config ?? {},
+ desired: webhookEnvironment.desiredConfig ?? {},
+ },
+ },
+ });
+ }
+}
diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts
index 79f299648..eae2ba59e 100644
--- a/apps/webapp/app/services/worker.server.ts
+++ b/apps/webapp/app/services/worker.server.ts
@@ -26,6 +26,7 @@ import { DeliverRunSubscriptionsService } from "./runs/deliverRunSubscriptions.s
import { ResumeTaskService } from "./tasks/resumeTask.server";
import { ExpireDispatcherService } from "./dispatchers/expireDispatcher.server";
import { InvokeEphemeralDispatcherService } from "./dispatchers/invokeEphemeralEventDispatcher.server";
+import { DeliverWebhookRequestService } from "./sources/deliverWebhookRequest.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -43,6 +44,7 @@ const workerCatalog = {
id: z.string(),
}),
deliverHttpSourceRequest: z.object({ id: z.string() }),
+ deliverWebhookRequest: z.object({ id: z.string() }),
refreshOAuthToken: z.object({
organizationId: z.string(),
connectionId: z.string(),
@@ -293,6 +295,16 @@ function getWorkerQueue() {
await service.call(payload.id);
},
},
+ deliverWebhookRequest: {
+ priority: 1, // smaller number = higher priority
+ maxAttempts: 14,
+ queueName: (payload) => `webhooks:${payload.id}`,
+ handler: async (payload, job) => {
+ const service = new DeliverWebhookRequestService();
+
+ await service.call(payload.id);
+ },
+ },
startRun: {
priority: 0, // smaller number = higher priority
maxAttempts: 4,
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 241eea6c5..8683d4ff5 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -1,4 +1,9 @@
-import type { Integration, TriggerHttpEndpoint, TriggerSource } from "@trigger.dev/database";
+import type {
+ Integration,
+ TriggerHttpEndpoint,
+ TriggerSource,
+ Webhook,
+} from "@trigger.dev/database";
import { z } from "zod";
import { Job } from "~/models/job.server";
import type { Organization } from "~/models/organization.server";
@@ -10,6 +15,7 @@ export type JobForPath = Pick;
export type RunForPath = Pick;
export type IntegrationForPath = Pick;
export type TriggerForPath = Pick;
+export type WebhookForPath = Pick;
export type HttpEndpointForPath = Pick;
export const OrganizationParamsSchema = z.object({
@@ -273,6 +279,82 @@ function triggerSourceParam(trigger: TriggerForPath) {
return trigger.id;
}
+export function projectWebhookTriggersPath(organization: OrgForPath, project: ProjectForPath) {
+ return `${projectTriggersPath(organization, project)}/webhooks`;
+}
+
+export function webhookTriggerPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath
+) {
+ return `${projectTriggersPath(organization, project)}/webhooks/${webhookSourceParam(webhook)}`;
+}
+
+export function webhookTriggerRunsParentPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath
+) {
+ return `${webhookTriggerPath(organization, project, webhook)}/runs`;
+}
+
+export function webhookTriggerRunPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath,
+ run: RunForPath
+) {
+ return `${webhookTriggerRunsParentPath(organization, project, webhook)}/${run.id}`;
+}
+
+export function webhookTriggerRunStreamingPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath,
+ run: RunForPath
+) {
+ return `${webhookTriggerRunPath(organization, project, webhook, run)}/stream`;
+}
+
+export function webhookDeliveryPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath
+) {
+ return `${webhookTriggerPath(organization, project, webhook)}/delivery`;
+}
+
+export function webhookTriggerDeliveryRunsParentPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath
+) {
+ return `${webhookTriggerRunsParentPath(organization, project, webhook)}/delivery`;
+}
+
+export function webhookTriggerDeliveryRunPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath,
+ run: RunForPath
+) {
+ return `${webhookTriggerDeliveryRunsParentPath(organization, project, webhook)}/${run.id}`;
+}
+
+export function webhookTriggerDeliveryRunStreamingPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ webhook: WebhookForPath,
+ run: RunForPath
+) {
+ return `${webhookTriggerDeliveryRunPath(organization, project, webhook, run)}/stream`;
+}
+
+function webhookSourceParam(webhook: WebhookForPath) {
+ return webhook.id;
+}
+
// Job
export function jobPath(organization: OrgForPath, project: ProjectForPath, job: JobForPath) {
return `${projectPath(organization, project)}/jobs/${jobParam(job)}`;
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 50ad62218..82c60d8f0 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -91,6 +91,7 @@
"marked": "^4.0.18",
"morgan": "^1.10.0",
"nanoid": "^3.3.4",
+ "ohash": "^1.1.3",
"postcss-import": "^14.1.0",
"posthog-js": "^1.83.0",
"posthog-node": "^3.1.1",
diff --git a/docs/integrations/apis/shopify-tasks.mdx b/docs/integrations/apis/shopify-tasks.mdx
new file mode 100644
index 000000000..d2ee78191
--- /dev/null
+++ b/docs/integrations/apis/shopify-tasks.mdx
@@ -0,0 +1,229 @@
+---
+title: Shopify Tasks
+sidebarTitle: Tasks
+---
+
+Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want.
+
+---
+
+## Tasks
+
+All tasks are using Shopify's [REST Resources](https://github.com/Shopify/shopify-api-js/blob/ff0900cd383712e6362b6dd3370d8ab11caabd3d/packages/shopify-api/docs/guides/rest-resources.md) and can be used with the same general pattern:
+
+```ts
+await io.shopify.rest..("cacheKey", params)
+```
+
+
+ Should be a stable and unique cache key inside the `run()`. See
+ [resumability](/documentation/concepts/resumability) for more information.
+
+
+ Resource-specific parameters.
+
+
+### `all()`
+
+Fetch all resources of a given type.
+
+```ts
+await io.shopify.rest.Variant.all("get-all-variants", {,
+ autoPaginate: true, // Pagination helper, disabled by default
+ product_id: 123456 // Optional, resource-specific parameter
+})
+```
+
+### `count()`
+
+Fetch the number of resources of a given type.
+
+```ts
+await io.shopify.rest.Product.count("count-products", {
+ product_type: "amazing stuff" // Optional, resource-specific parameters
+})
+```
+
+### `find()`
+
+Fetch a single resource by its ID.
+
+```ts
+await io.shopify.rest.Product.find("find-product", {
+ id: 123456
+})
+```
+
+### `save()`
+
+Create or update a resource of a given type. The resource will be created if no ID is specified.
+
+```ts
+// Create a product
+await io.shopify.rest.Product.save("create-product", {
+ fromData: {
+ title: "Some Product",
+ },
+})
+
+// Update a product
+await io.shopify.rest.Product.save("update-product", {
+ fromData: {
+ id: 123456
+ title: "New Product Name",
+ },
+})
+```
+
+### `delete()`
+
+Delete an existing resource.
+
+```ts
+await io.shopify.rest.Product.delete("delete-product", {
+ id: 123456
+})
+```
+
+## Resources
+
+This is a list of REST Resources that can be used directly as Tasks. They all implement the same methods described above. For resources with non-standard methods, you will have to use the raw Shopify API Client instead - please see the end of this page for further instructions.
+
+- [Article](https://shopify.dev/docs/api/admin-rest/2023-07/resources/article)
+- [Blog](https://shopify.dev/docs/api/admin-rest/2023-07/resources/blog)
+- [Collect](https://shopify.dev/docs/api/admin-rest/2023-07/resources/collect)
+- [Country](https://shopify.dev/docs/api/admin-rest/2023-07/resources/country)
+- [CustomCollection](https://shopify.dev/docs/api/admin-rest/2023-07/resources/customcollection)
+- [Customer](https://shopify.dev/docs/api/admin-rest/2023-07/resources/customer)
+- [DiscountCode](https://shopify.dev/docs/api/admin-rest/2023-07/resources/discountCode)
+- [DraftOrder](https://shopify.dev/docs/api/admin-rest/2023-07/resources/draftOrder)
+- [Image](https://shopify.dev/docs/api/admin-rest/2023-07/resources/image)
+- [MarketingEvent](https://shopify.dev/docs/api/admin-rest/2023-07/resources/marketingevent)
+- [MetaField](https://shopify.dev/docs/api/admin-rest/2023-07/resources/metafield)
+- [Order](https://shopify.dev/docs/api/admin-rest/2023-07/resources/order)
+- [Page](https://shopify.dev/docs/api/admin-rest/2023-07/resources/page)
+- [PriceRule](https://shopify.dev/docs/api/admin-rest/2023-07/resources/pricerule)
+- [Product](https://shopify.dev/docs/api/admin-rest/2023-07/resources/product)
+- [Redirect](https://shopify.dev/docs/api/admin-rest/2023-07/resources/redirect)
+- [ScriptTag](https://shopify.dev/docs/api/admin-rest/2023-07/resources/scripttag)
+- [SmartCollection](https://shopify.dev/docs/api/admin-rest/2023-07/resources/smartcollection)
+- [Variant](https://shopify.dev/docs/api/admin-rest/2023-07/resources/variant)
+- [Webhook](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook)
+
+
+## Example usage
+
+In this example we'll create some products in response to a customer sign-up, count them all before and after, and do a few other things too.
+
+```ts
+client.defineJob({
+ id: "shopify-integration-on-customer-created",
+ name: "Shopify Integration - On Customer Created",
+ version: "1.0.0",
+ integrations: {
+ shopify
+ },
+ trigger: shopify.on("customers/create"),
+ run: async (payload, io, ctx) => {
+ const pre = await io.shopify.rest.Product.count("count-products");
+
+ const firstName = payload.first_name;
+
+ // Create a customized product
+ const productOne = await io.shopify.rest.Product.save("create-product-one", {
+ fromData: {
+ title: `${firstName}'s Teapot`,
+ },
+ });
+
+ // ..and another one
+ const productTwo = await io.shopify.rest.Product.save("create-product-two", {
+ fromData: {
+ title: `${firstName}'s Mug`,
+ },
+ });
+
+ const post = await io.shopify.rest.Product.count("count-products-again");
+
+ await io.logger.info(`Created products: ${post.count - pre.count}`)
+
+ // Use our fancy pagination helper
+ const allProducts = await io.shopify.rest.Product.all("get-all-products", {
+ limit: 1,
+ autoPaginate: true,
+ });
+
+
+ const productNames = allProducts.data.map(p => p.title).join(", ")
+
+ await io.logger.info(`All product names: ${productNames}`)
+
+ const foundOne = await io.shopify.rest.Product.find("find-product", {
+ id: productOne.id,
+ });
+
+ if (foundOne) {
+ // Get those variants, because we can
+ await io.shopify.rest.Variant.all("get-all-variants", {
+ product_id: foundOne.id,
+ });
+
+ // Maybe that teapot was a bit too much
+ await io.shopify.rest.Product.delete("delete-product", {
+ id: foundOne.id,
+ });
+ }
+
+ return {
+ message: `Hi, ${firstName}! Bet you'll love this item: ${productTwo.title}`
+ }
+ },
+});
+```
+
+## Using the underlying Shopify API Client
+
+You can access the [Shopify API Client instance](https://github.com/Shopify/shopify-api-js/blob/ff0900cd383712e6362b6dd3370d8ab11caabd3d/packages/shopify-api/docs/reference/shopifyApi.md) by using the `runTask` method on the integration:
+
+```ts
+const shopify = new Shopify({
+ id: "shopify",
+});
+
+client.defineJob({
+ id: "shopify-example-1",
+ name: "Shopify Example 1",
+ version: "0.1.0",
+ trigger: eventTrigger({
+ name: "shopify.example",
+ }),
+ integrations: {
+ shopify,
+ },
+ run: async (payload, io, ctx) => {
+ const newProduct = await io.shopify.runTask(
+ "create-product",
+ async (client, task, io, session) => {
+ // We create a session for you to pass to the client
+ const product = new client.rest.Product({ session });
+
+ product.title = "Rick's Amazing Teapot";
+ product.body_html = "What a great teapot!";
+ product.vendor = "Astley Inc.";
+ product.product_type = "Teapot";
+ product.status = "active";
+
+ // This will create the product and update the object
+ await product.save({ update: true });
+
+ return product;
+ }
+ );
+
+ return {
+ status: 418,
+ statusText: `I'm ${newProduct.title}`,
+ };
+ },
+});
+```
diff --git a/docs/integrations/apis/shopify-triggers.mdx b/docs/integrations/apis/shopify-triggers.mdx
new file mode 100644
index 000000000..9e630dc2b
--- /dev/null
+++ b/docs/integrations/apis/shopify-triggers.mdx
@@ -0,0 +1,852 @@
+---
+title: Shopify Triggers & Events
+sidebarTitle: Triggers & Events
+---
+
+You can use these triggers to start a job when a Shopify event occurs.
+
+---
+
+## Triggers
+
+All triggers can be create folllowing the same pattern:
+
+```ts
+shopify.on("topic")
+```
+
+
+ The webhook topic you want to subscribe to. Generally a pattern of `/`.
+
+
+### Helpers
+
+The `filter()` method returns a new trigger with the applied payload filter:
+
+```ts
+const trigger = shopify.on("topic").filter(filter)
+```
+
+
+ A filter to apply to the event. See our [EventFilter guide](/documentation/guides/event-filter).
+
+
+## Events
+
+What follows is a small selection of webhook topics and associated payloads. A complete list of possible topic names and payloads can be found [here](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics).
+
+
+### `fulfillments/create`
+
+Occurs when a fulfillment is created. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-fulfillments-create).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("fulfillments/create"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": 123456,
+ "order_id": 820982911946154500,
+ "status": "pending",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "service": null,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "tracking_company": "UPS",
+ "shipment_status": null,
+ "location_id": null,
+ "origin_address": null,
+ "email": "jon@example.com",
+ "destination": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "line_items": [
+ {
+ "id": 866550311766439000,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "0.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020",
+ "tax_lines": []
+ },
+ {
+ "id": 141249953214522980,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "5.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [
+ {
+ "amount": "5.00",
+ "discount_application_index": 0,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ },
+ {
+ "amount": "5.00",
+ "discount_application_index": 2,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ }
+ ],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974",
+ "tax_lines": []
+ }
+ ],
+ "tracking_number": "1z827wk74630",
+ "tracking_numbers": ["1z827wk74630"],
+ "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630",
+ "tracking_urls": [
+ "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630"
+ ],
+ "receipt": {},
+ "name": "#9999.1",
+ "admin_graphql_api_id": "gid://shopify/Fulfillment/123456"
+}
+```
+
+
+### `inventory_items/update`
+
+Occurs when an inventory item is updated. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-inventory-items-update).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("inventory_items/update"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": 271878346596884000,
+ "sku": "example-sku",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "requires_shipping": true,
+ "cost": null,
+ "country_code_of_origin": null,
+ "province_code_of_origin": null,
+ "harmonized_system_code": null,
+ "tracked": true,
+ "country_harmonized_system_codes": [],
+ "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015"
+}
+```
+
+
+### `orders/delete`
+
+Occurs when a order is deleted. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-orders-delete).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("orders/delete"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": 820982911946154500
+}
+```
+
+
+### `orders/paid`
+
+Occurs when an order is paid. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-orders-paid).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("orders/paid"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": 820982911946154500,
+ "admin_graphql_api_id": "gid://shopify/Order/820982911946154508",
+ "app_id": null,
+ "browser_ip": null,
+ "buyer_accepts_marketing": true,
+ "cancel_reason": "customer",
+ "cancelled_at": "2021-12-31T19:00:00-05:00",
+ "cart_token": null,
+ "checkout_id": null,
+ "checkout_token": null,
+ "client_details": null,
+ "closed_at": null,
+ "confirmation_number": null,
+ "confirmed": false,
+ "contact_email": "jon@example.com",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "currency": "USD",
+ "current_subtotal_price": "398.00",
+ "current_subtotal_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_additional_fees_set": null,
+ "current_total_discounts": "0.00",
+ "current_total_discounts_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_duties_set": null,
+ "current_total_price": "398.00",
+ "current_total_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_tax": "0.00",
+ "current_total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "customer_locale": "en",
+ "device_id": null,
+ "discount_codes": [],
+ "email": "jon@example.com",
+ "estimated_taxes": false,
+ "financial_status": "voided",
+ "fulfillment_status": "pending",
+ "landing_site": null,
+ "landing_site_ref": null,
+ "location_id": null,
+ "merchant_of_record_app_id": null,
+ "name": "#9999",
+ "note": null,
+ "note_attributes": [],
+ "number": 234,
+ "order_number": 1234,
+ "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg",
+ "original_total_additional_fees_set": null,
+ "original_total_duties_set": null,
+ "payment_gateway_names": [
+ "visa",
+ "bogus"
+ ],
+ "phone": null,
+ "po_number": null,
+ "presentment_currency": "USD",
+ "processed_at": null,
+ "reference": null,
+ "referring_site": null,
+ "source_identifier": null,
+ "source_name": "web",
+ "source_url": null,
+ "subtotal_price": "388.00",
+ "subtotal_price_set": {
+ "shop_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ }
+ },
+ "tags": "",
+ "tax_exempt": false,
+ "tax_lines": [],
+ "taxes_included": false,
+ "test": true,
+ "token": "123456abcd",
+ "total_discounts": "20.00",
+ "total_discounts_set": {
+ "shop_money": {
+ "amount": "20.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "20.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_line_items_price": "398.00",
+ "total_line_items_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_outstanding": "398.00",
+ "total_price": "388.00",
+ "total_price_set": {
+ "shop_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_shipping_price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tax": "0.00",
+ "total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tip_received": "0.00",
+ "total_weight": 0,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "user_id": null,
+ "billing_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "customer": {
+ "id": 115310627314723950,
+ "email": "john@example.com",
+ "accepts_marketing": false,
+ "created_at": null,
+ "updated_at": null,
+ "first_name": "John",
+ "last_name": "Smith",
+ "state": "disabled",
+ "note": null,
+ "verified_email": true,
+ "multipass_identifier": null,
+ "tax_exempt": false,
+ "phone": null,
+ "email_marketing_consent": {
+ "state": "not_subscribed",
+ "opt_in_level": null,
+ "consent_updated_at": null
+ },
+ "sms_marketing_consent": null,
+ "tags": "",
+ "currency": "USD",
+ "accepts_marketing_updated_at": null,
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954",
+ "default_address": {
+ "id": 715243470612851200,
+ "customer_id": 115310627314723950,
+ "first_name": null,
+ "last_name": null,
+ "company": null,
+ "address1": "123 Elm St.",
+ "address2": null,
+ "city": "Ottawa",
+ "province": "Ontario",
+ "country": "Canada",
+ "zip": "K2H7A8",
+ "phone": "123-123-1234",
+ "name": "",
+ "province_code": "ON",
+ "country_code": "CA",
+ "country_name": "Canada",
+ "default": true
+ }
+ },
+ "discount_applications": [],
+ "fulfillments": [],
+ "line_items": [
+ {
+ "id": 866550311766439000,
+ "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020",
+ "attributed_staffs": [
+ {
+ "id": "gid://shopify/StaffMember/902541635",
+ "quantity": 1
+ }
+ ],
+ "fulfillable_quantity": 1,
+ "fulfillment_service": "manual",
+ "fulfillment_status": null,
+ "gift_card": false,
+ "grams": 567,
+ "name": "IPod Nano - 8GB",
+ "price": "199.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "product_exists": true,
+ "product_id": 632910392,
+ "properties": [],
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "total_discount": "0.00",
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "variant_id": 808950810,
+ "variant_inventory_management": "shopify",
+ "variant_title": null,
+ "vendor": null,
+ "tax_lines": [],
+ "duties": [],
+ "discount_allocations": []
+ },
+ {
+ "id": 141249953214522980,
+ "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974",
+ "attributed_staffs": [],
+ "fulfillable_quantity": 1,
+ "fulfillment_service": "manual",
+ "fulfillment_status": null,
+ "gift_card": false,
+ "grams": 567,
+ "name": "IPod Nano - 8GB",
+ "price": "199.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "product_exists": true,
+ "product_id": 632910392,
+ "properties": [],
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "total_discount": "0.00",
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "variant_id": 808950810,
+ "variant_inventory_management": "shopify",
+ "variant_title": null,
+ "vendor": null,
+ "tax_lines": [],
+ "duties": [],
+ "discount_allocations": []
+ }
+ ],
+ "payment_terms": null,
+ "refunds": [],
+ "shipping_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "shipping_lines": [
+ {
+ "id": 271878346596884000,
+ "carrier_identifier": null,
+ "code": null,
+ "discounted_price": "10.00",
+ "discounted_price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "phone": null,
+ "price": "10.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "requested_fulfillment_service_id": null,
+ "source": "shopify",
+ "title": "Generic Shipping",
+ "tax_lines": [],
+ "discount_allocations": []
+ }
+ ]
+}
+```
+
+
+### `products/create`
+
+Occurs when a product is created. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-products-create).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("products/create"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "admin_graphql_api_id": "gid://shopify/Product/788032119674292922",
+ "body_html": "An example T-Shirt",
+ "created_at": null,
+ "handle": "example-t-shirt",
+ "id": 788032119674292900,
+ "product_type": "Shirts",
+ "published_at": "2021-12-31T19:00:00-05:00",
+ "template_suffix": null,
+ "title": "Example T-Shirt",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "vendor": "Acme",
+ "status": "active",
+ "published_scope": "web",
+ "tags": "example, mens, t-shirt",
+ "variants": [
+ {
+ "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922",
+ "barcode": null,
+ "compare_at_price": "24.99",
+ "created_at": null,
+ "fulfillment_service": "manual",
+ "id": 642667041472714000,
+ "inventory_management": "shopify",
+ "inventory_policy": "deny",
+ "position": 0,
+ "price": "19.99",
+ "product_id": 788032119674292900,
+ "sku": "example-shirt-s",
+ "taxable": true,
+ "title": "",
+ "updated_at": null,
+ "option1": "Small",
+ "option2": null,
+ "option3": null,
+ "grams": 200,
+ "image_id": null,
+ "weight": 200,
+ "weight_unit": "g",
+ "inventory_item_id": null,
+ "inventory_quantity": 75,
+ "old_inventory_quantity": 75,
+ "requires_shipping": true
+ },
+ {
+ "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962",
+ "barcode": null,
+ "compare_at_price": "24.99",
+ "created_at": null,
+ "fulfillment_service": "manual",
+ "id": 757650484644203900,
+ "inventory_management": "shopify",
+ "inventory_policy": "deny",
+ "position": 0,
+ "price": "19.99",
+ "product_id": 788032119674292900,
+ "sku": "example-shirt-m",
+ "taxable": true,
+ "title": "",
+ "updated_at": null,
+ "option1": "Medium",
+ "option2": null,
+ "option3": null,
+ "grams": 200,
+ "image_id": null,
+ "weight": 200,
+ "weight_unit": "g",
+ "inventory_item_id": null,
+ "inventory_quantity": 50,
+ "old_inventory_quantity": 50,
+ "requires_shipping": true
+ }
+ ],
+ "options": [],
+ "images": [],
+ "image": null
+}
+```
+
+
+### `products/delete`
+
+Occurs when a product is deleted. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-products-delete).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("products/delete"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": 1
+}
+```
+
+
+### `subscription_billing_attempts/failure`
+
+Occurs when a subscription billing attempt has failed. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-subscription-billing-attempts-failure).
+
+```ts usage.ts
+client.defineJob({
+ id: "",
+ name: "",
+ version: "0.1.0",
+ trigger: shopify.on("subscription_billing_attempts/failure"),
+ run: async (payload, io, ctx) => {
+ // Add tasks here
+ },
+});
+```
+
+
+```json
+{
+ "id": null,
+ "admin_graphql_api_id": null,
+ "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9",
+ "order_id": 1,
+ "admin_graphql_api_order_id": "gid://shopify/Order/1",
+ "subscription_contract_id": 9251185925,
+ "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9251185925",
+ "ready": true,
+ "error_message": null,
+ "error_code": null
+}
+```
+
diff --git a/docs/integrations/apis/shopify.mdx b/docs/integrations/apis/shopify.mdx
new file mode 100644
index 000000000..91d6617a6
--- /dev/null
+++ b/docs/integrations/apis/shopify.mdx
@@ -0,0 +1,75 @@
+---
+title: Shopify overview & authentication
+sidebarTitle: Overview & authentication
+---
+
+## Overview
+
+Our Shopify integration allows you to create triggers and tasks that interact with Shopify. Trigger jobs when events happen, like when a new product is added to a shop, or when an order is paid for, etc. You can also perform tasks like creating products, editing variants, getting information about an order, and a lot more.
+
+{/*
+ Check out pre-built Shopify jobs in our showcase.
+ */}
+
+## Installing the Shopify packages
+
+To get started with our Shopify integration, you need to install the `@trigger.dev/shopify` packages. You can do this using `npm`, `pnpm`, or `yarn`:
+
+
+
+```bash npm
+npm install @trigger.dev/shopify@latest
+```
+
+```bash pnpm
+pnpm install @trigger.dev/shopify@latest
+```
+
+```bash yarn
+yarn add @trigger.dev/shopify@latest
+```
+
+
+
+## Authentication
+
+You can use Personal Access Tokens to authenticate with Shopify and get started with building custom apps.
+
+### Personal Access Tokens
+
+To create the tokens on Shopify, login and [follow the instructions](https://help.shopify.com/en/manual/apps/app-types/custom-apps#create-and-install-a-custom-app).
+
+The [required scopes](https://shopify.dev/docs/api/usage/access-scopes#authenticated-access-scopes) depend on the tasks you wish to perform and which webhooks you intend to receive. Webhooks will generally need read access to the respective Shopify resource.
+
+Additionally, you will also have to provide your shop domain.
+
+```ts my-job.ts
+import { Shopify } from "@trigger.dev/shopify";
+
+//create Shopify client using a token
+const shopify = new Shopify({
+ id: "shopify",
+ adminAccessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
+ apiKey: process.env.SHOPIFY_API_KEY!,
+ apiSecretKey: process.env.SHOPIFY_API_SECRET_KEY!,
+ hostName: process.env.SHOPIFY_SHOP_DOMAIN!,
+});
+...
+```
+
+## Triggers and Tasks
+
+Once you have set up a Shopify client, you can use it to create triggers and tasks.
+
+
+
+ Trigger Jobs when events happen in Shopify, like a deleted product or a paid order.
+
+
+ Perform Tasks such as creating new variants, or editing orders, and more.
+
+
diff --git a/docs/integrations/introduction.mdx b/docs/integrations/introduction.mdx
index 05fa73abe..a4bab4508 100644
--- a/docs/integrations/introduction.mdx
+++ b/docs/integrations/introduction.mdx
@@ -42,6 +42,7 @@ Navigate the menu or select Integrations from the table below.
| [Replicate](/integrations/apis/replicate) | Run machine learning tasks easily at scale | N/A | ✅ |
| [Resend](/integrations/apis/resend) | Send emails using Resend | 🕘 | ✅ |
| [SendGrid](/integrations/apis/sendgrid) | Send emails using SendGrid | 🕘 | ✅ |
+| [Shopify](/integrations/apis/shopify) | Interact with the Shopify Admin API | ✅ | ✅ |
| [Slack](/integrations/apis/slack) | Send Slack messages | 🕘 | ✅ |
| [Stripe](/integrations/apis/stripe) | Interact with the Stripe API | ✅ | ✅ |
| [Supabase](/integrations/apis/supabase) | Interact with your projects and databases | ✅ | ✅ |
diff --git a/docs/mint.json b/docs/mint.json
index 4964b6061..4dd57930c 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -290,6 +290,14 @@
"group": "Resend",
"pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"]
},
+ {
+ "group": "Shopify",
+ "pages": [
+ "integrations/apis/shopify",
+ "integrations/apis/shopify-triggers",
+ "integrations/apis/shopify-tasks"
+ ]
+ },
{
"group": "Slack",
"pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"]
@@ -323,6 +331,7 @@
"sdk/triggerclient/instancemethods/getevent",
"sdk/triggerclient/instancemethods/cancel-event",
"sdk/triggerclient/instancemethods/cancel-runs-for-event",
+ "sdk/triggerclient/store",
"sdk/triggerclient/instancemethods/getruns",
"sdk/triggerclient/instancemethods/getrun",
"sdk/triggerclient/instancemethods/define-job",
@@ -343,6 +352,7 @@
"sdk/io/runtask",
"sdk/io/sendevent",
"sdk/io/sendevents",
+ "sdk/io/store",
"sdk/io/wait",
"sdk/io/wait-for-event",
"sdk/io/wait-for-request",
diff --git a/docs/sdk/io/overview.mdx b/docs/sdk/io/overview.mdx
index 2a45d0b47..4f8e82692 100644
--- a/docs/sdk/io/overview.mdx
+++ b/docs/sdk/io/overview.mdx
@@ -20,6 +20,10 @@ View [the Integrations documentation](/integrations) for information on how to u
Used to send log messages to the [Run log](/documentation/guides/viewing-runs).
+### [store](/sdk/io/store)
+
+Exposes namespaced [Key-Value Stores](/sdk/io/store) you can access inside of your Jobs.
+
## Instance methods
### [runTask()](/sdk/io/runtask)
diff --git a/docs/sdk/io/store.mdx b/docs/sdk/io/store.mdx
new file mode 100644
index 000000000..b6a80b2f5
--- /dev/null
+++ b/docs/sdk/io/store.mdx
@@ -0,0 +1,114 @@
+---
+title: "store"
+sidebarTitle: "store"
+description: "Exposes namespaced **Key-Value Stores** you can access inside of your Jobs."
+---
+
+
+ Only use this for small values - there's a **256KB** per-item size limit.
+
+
+## Namespaces
+
+- `store.env` to access and store data within the **Environment**
+- `store.job` to access and store data within the **Job**
+- `store.run` to access and store data within the **Run**
+
+## Methods
+
+### `delete()`
+
+Deletes an item from the Key-Value Store.
+
+
+
+
+ The `key` of the item to delete.
+
+
+#### Returns
+
+A `Promise` that resolves when the item has been deleted.
+
+```ts
+await client.store.env.delete("cacheKey", "key")
+```
+
+### `has()`
+
+Checks if an item exists in the Key-Value Store.
+
+
+
+
+ The `key` of the item to check existence of.
+
+
+#### Returns
+
+A `Promise` that resolves to a `boolean` value indicating existence.
+
+```ts
+const exists = await client.store.env.has("cacheKey", "key")
+```
+
+### `get()`
+
+Retrieves an item from the Key-Value Store.
+
+
+
+
+ The `key` of the item to retrieve.
+
+
+#### Returns
+
+A `Promise` that resolves to the stored value or `undefined` if missing.
+
+```ts
+const val = await client.store.env.get("cacheKey", "key")
+```
+
+### `set()`
+
+Stores an item in the Key-Value Store.
+
+
+
+
+ The `key` of the item to store.
+
+
+
+ The serializable `value` to store.
+
+
+```ts
+const val = await client.store.env.set("cacheKey", "key", "value")
+```
+
+#### Returns
+
+A `Promise` that resolves to the stored value.
+
+
+```ts Example
+await client.store.env.set("key", "foo")
+await client.store.job.set("key", "bar")
+await client.store.run.set("key", "baz")
+
+await client.store.env.get("key") // "foo"
+await client.store.job.get("key") // "bar"
+await client.store.run.get("key") // "baz"
+
+await client.store.env.has("key") // true
+await client.store.job.has("missing") // false
+await client.store.run.has("key") // true
+
+// cleanup
+await client.store.env.delete("key")
+await client.store.job.delete("key")
+await client.store.run.delete("key")
+```
+
diff --git a/docs/sdk/triggerclient/overview.mdx b/docs/sdk/triggerclient/overview.mdx
index dcc5d4f36..319b6c61f 100644
--- a/docs/sdk/triggerclient/overview.mdx
+++ b/docs/sdk/triggerclient/overview.mdx
@@ -30,6 +30,10 @@ Creates a new TriggerClient object.
Is used to uniquely identify the client.
+
+ Exposes namespaced [Key-Value Stores](/sdk/triggerclient/instancemethods/store) you can access in and outside of your Jobs.
+
+
## Instance methods
#### [sendEvent()](/sdk/triggerclient/instancemethods/sendevent)
diff --git a/docs/sdk/triggerclient/store.mdx b/docs/sdk/triggerclient/store.mdx
new file mode 100644
index 000000000..c834dc126
--- /dev/null
+++ b/docs/sdk/triggerclient/store.mdx
@@ -0,0 +1,105 @@
+---
+title: "store"
+sidebarTitle: "store"
+description: "Exposes namespaced **Key-Value Stores** you can access in and outside of your Jobs."
+---
+
+
+ Only use this for small values - there's a **256KB** per-item size limit.
+
+
+## Namespaces
+
+- `store.env` to access and store data across your **Environment**
+
+## Methods
+
+### `delete()`
+
+Deletes an item from the Key-Value Store.
+
+
+ The `key` of the item to delete.
+
+
+#### Returns
+
+A `Promise` that resolves when the item has been deleted.
+
+```ts
+await client.store.env.delete("key")
+```
+
+### `has()`
+
+Checks if an item exists in the Key-Value Store.
+
+
+ The `key` of the item to check existence of.
+
+
+#### Returns
+
+A `Promise` that resolves to a `boolean` value indicating existence.
+
+```ts
+const exists = await client.store.env.has("key")
+```
+
+### `get()`
+
+Retrieves an item from the Key-Value Store.
+
+
+ The `key` of the item to retrieve.
+
+
+#### Returns
+
+A `Promise` that resolves to the stored value or `undefined` if missing.
+
+```ts
+const val = await client.store.env.get("key")
+```
+
+### `set()`
+
+Stores an item in the Key-Value Store.
+
+
+ The `key` of the item to store.
+
+
+
+ The serializable `value` to store.
+
+
+```ts
+const val = await client.store.env.set("key", "value")
+```
+
+#### Returns
+
+A `Promise` that resolves to the stored value.
+
+
+```ts Example
+// returns: "value"
+await client.store.env.set("key", "value")
+
+// returns: true
+await client.store.env.has("key")
+
+// returns: "value"
+await client.store.env.get("key")
+
+// returns: void
+await client.store.env.delete("key")
+
+// returns: false
+await client.store.env.has("key")
+
+// returns: { foo: "bar" }
+await client.store.env.set("obj", { foo: "bar" })
+```
+
diff --git a/docs/sdk/verify-request-signature.mdx b/docs/sdk/verify-request-signature.mdx
index 106a476ee..7cbf53b6a 100644
--- a/docs/sdk/verify-request-signature.mdx
+++ b/docs/sdk/verify-request-signature.mdx
@@ -41,6 +41,9 @@ const caldotcom = client.defineHttpEndpoint({
The name of the header that contains the signature. E.g. `X-Cal-Signature-256`.
+
+ The header encoding. Defaults to `hex`.
+
The secret that you use to hash the payload. For HttpEndpoints this will usually originally
come from the Trigger.dev dashboard and should be stored in an environment variable.
diff --git a/integrations/airtable/src/index.ts b/integrations/airtable/src/index.ts
index bc9ff2402..691cb8793 100644
--- a/integrations/airtable/src/index.ts
+++ b/integrations/airtable/src/index.ts
@@ -10,9 +10,16 @@ import {
type RunTaskOptions,
type TriggerIntegration,
} from "@trigger.dev/sdk";
-import AirtableSDK from "airtable";
+import AirtableSDK, { Error as AirtableApiError } from "airtable";
import { Base } from "./base";
-import { Webhooks, createWebhookEventSource } from "./webhooks";
+import * as events from "./events";
+import {
+ WebhookChangeType,
+ WebhookDataType,
+ Webhooks,
+ createWebhookSource,
+ createWebhookTrigger,
+} from "./webhooks";
export * from "./types";
export * from "./base";
@@ -57,7 +64,7 @@ export class Airtable implements TriggerIntegration {
}
get source() {
- return createWebhookEventSource(this);
+ return createWebhookSource(this);
}
cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) {
@@ -105,7 +112,7 @@ export class Airtable implements TriggerIntegration {
...(options ?? {}),
connectionKey: this._connectionKey,
},
- errorCallback
+ errorCallback ?? onError
);
}
@@ -120,8 +127,8 @@ export class Airtable implements TriggerIntegration {
// changeTypes?: WebhookChangeType[];
// dataTypes?: WebhookDataType[];
// }) {
- // return createTrigger(this.source, events.onTableChanged, params, {
- // changeTypes: params.changeTypes,
+ // return createWebhookTrigger(this.source, events.onTableChanged, params, {
+ // changeTypes: params.changeTypes ?? ["add", "remove", "update"],
// dataTypes: ["tableData", "tableFields", "tableMetadata"],
// });
// }
@@ -130,3 +137,37 @@ export class Airtable implements TriggerIntegration {
return new Webhooks(this.runTask.bind(this));
}
}
+
+function isAirtableApiError(error: unknown): error is AirtableApiError {
+ if (typeof error !== "object" || error === null) {
+ return false;
+ }
+
+ const airtableError = error as AirtableApiError;
+
+ return (
+ typeof airtableError.error === "string" &&
+ typeof airtableError.message === "string" &&
+ typeof airtableError.statusCode === "number"
+ );
+}
+
+export function onError(error: unknown): ReturnType {
+ if (!isAirtableApiError(error)) {
+ return;
+ }
+
+ if (error.statusCode === 429) {
+ // see: https://airtable.com/developers/web/api/rate-limits
+ return {
+ retryAt: new Date(Date.now() + 30 * 1000),
+ };
+ }
+
+ if (error.statusCode >= 400 && error.statusCode < 500) {
+ // see: https://airtable.com/developers/web/api/errors#user-error-codes
+ return {
+ skipRetrying: true,
+ };
+ }
+}
diff --git a/integrations/airtable/src/webhooks.ts b/integrations/airtable/src/webhooks.ts
index 786d9ef7e..98ca0c2c3 100644
--- a/integrations/airtable/src/webhooks.ts
+++ b/integrations/airtable/src/webhooks.ts
@@ -1,16 +1,11 @@
-import {
- EventFilter,
- ExternalSource,
- ExternalSourceTrigger,
- HandlerEvent,
- IntegrationTaskKey,
- Logger,
-} from "@trigger.dev/sdk";
-import AirtableSDK from "airtable";
+import { EventFilter, IntegrationTaskKey, verifyRequestSignature } from "@trigger.dev/sdk";
+import AirtableSDK, { Error as AirtableApiError } from "airtable";
import { z } from "zod";
import * as events from "./events";
import { Airtable, AirtableRunTask } from "./index";
import { ListWebhooksResponse, ListWebhooksResponseSchema } from "./schemas";
+import { WebhookSource, WebhookTrigger } from "@trigger.dev/sdk/triggers/webhook";
+import { registerJobNamespace } from "@trigger.dev/integration-kit/webhooks";
const WebhookFromSourceSchema = z.union([
z.literal("formSubmission"),
@@ -25,17 +20,21 @@ const WebhookFromSourceSchema = z.union([
]);
type WebhookFromSource = z.infer;
+
const WebhookDataTypeSchema = z.union([
z.literal("tableData"),
z.literal("tableFields"),
z.literal("tableMetadata"),
]);
+
export type WebhookDataType = z.infer;
+
const WebhookChangeTypeSchema = z.union([
z.literal("add"),
z.literal("remove"),
z.literal("update"),
]);
+
export type WebhookChangeType = z.infer;
type WebhookSpecification = {
filters: {
@@ -46,6 +45,31 @@ type WebhookSpecification = {
};
};
+const AirtableErrorBodySchema = z
+ .union([
+ z.object({
+ error: z.string(),
+ }),
+ z.object({
+ error: z.object({
+ type: z.string(),
+ message: z.string().optional(),
+ }),
+ }),
+ ])
+ .transform((body) => {
+ if (typeof body.error === "string") {
+ return {
+ type: body.error,
+ };
+ } else {
+ return {
+ type: body.error.type,
+ message: body.error.message,
+ };
+ }
+ });
+
const apiUrl = "https://api.airtable.com/v0/bases";
export class Webhooks {
@@ -62,7 +86,6 @@ export class Webhooks {
return this.runTask(
key,
async (client, task, io) => {
- // create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks`, {
method: "POST",
headers: {
@@ -85,14 +108,7 @@ export class Webhooks {
});
if (!response.ok) {
- const errorText = await response
- .text()
- .then((t) => t)
- .catch((e) => "No body");
-
- throw new Error(
- `Failed to create webhook: ${response.status} ${response.statusText}\n${errorText}`
- );
+ await handleWebhookError(response, "WEBHOOK_CREATE");
}
const webhook = await response.json();
@@ -114,7 +130,6 @@ export class Webhooks {
return this.runTask(
key,
async (client, task, io) => {
- // create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks`, {
headers: {
Authorization: `Bearer ${client._apiKey}`,
@@ -123,7 +138,7 @@ export class Webhooks {
});
if (!response.ok) {
- throw new Error(`Failed to list webhooks: ${response.statusText}`);
+ await handleWebhookError(response, "WEBHOOK_LIST");
}
const webhook = await response.json();
@@ -143,7 +158,6 @@ export class Webhooks {
return this.runTask(
key,
async (client, task, io) => {
- // create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks/${webhookId}`, {
method: "DELETE",
headers: {
@@ -153,7 +167,7 @@ export class Webhooks {
});
if (!response.ok) {
- throw new Error(`Failed to delete webhook: ${response.statusText}`);
+ await handleWebhookError(response, "WEBHOOK_DELETE");
}
},
{
@@ -187,26 +201,26 @@ export type TriggerParams = {
filter?: EventFilter;
};
-type CreateTriggersResult = ExternalSourceTrigger<
+type CreateWebhookTriggersResult = WebhookTrigger<
TEventSpecification,
- ReturnType
+ ReturnType
>;
-export function createTrigger(
- source: ReturnType,
+export function createWebhookTrigger(
+ source: ReturnType,
event: TEventSpecification,
params: TriggerParams,
- options: {
+ config: {
dataTypes: WebhookDataType[];
changeTypes?: WebhookChangeType[];
fromSources?: WebhookFromSource[];
}
-): CreateTriggersResult {
- return new ExternalSourceTrigger({
+): CreateWebhookTriggersResult {
+ return new WebhookTrigger({
event,
params,
source,
- options,
+ config,
});
}
@@ -232,21 +246,39 @@ const WebhookListDataSchema = z.object({
type WebhookListData = z.infer;
-export function createWebhookEventSource(
+const getSpecification = (config: Record, params: any): WebhookSpecification => {
+ return {
+ filters: {
+ dataTypes: config.dataTypes as WebhookDataType[],
+ changeTypes: config.changeTypes
+ ? (config.changeTypes as WebhookChangeType[])
+ : ["add", "remove", "update"],
+ fromSources: (config.fromSources ?? [
+ "client",
+ "anonymousUser",
+ "formSubmission",
+ ]) as WebhookFromSource[],
+ recordChangeScope: params?.tableId,
+ },
+ };
+};
+
+export function createWebhookSource(
integration: Airtable
-): ExternalSource<
+): WebhookSource<
Airtable,
{ baseId: string; tableId?: string },
- "HTTP",
{ dataTypes: WebhookDataType[]; fromSources?: WebhookFromSource[] }
> {
- return new ExternalSource("HTTP", {
+ return new WebhookSource({
id: "airtable.webhook",
- schema: z.object({ baseId: z.string(), tableId: z.string().optional() }),
- optionSchema: z.object({
- dataTypes: z.array(WebhookDataTypeSchema),
- fromSources: z.array(WebhookFromSourceSchema).optional(),
- }),
+ schemas: {
+ params: z.object({ baseId: z.string(), tableId: z.string().optional() }),
+ config: z.object({
+ dataTypes: z.array(WebhookDataTypeSchema),
+ fromSources: z.array(WebhookFromSourceSchema).optional(),
+ }),
+ },
version: "0.1.0",
integration,
filter: (params, options) => ({
@@ -256,83 +288,89 @@ export function createWebhookEventSource(
}),
key: (params) =>
`airtable.webhook.${params.baseId}${params.tableId ? `.${params.tableId}` : ""}`,
- handler: webhookHandler,
- register: async (event, io, ctx) => {
- const { params, source: httpSource, options } = event;
-
- const webhookData = WebhookRegistrationDataSchema.safeParse(httpSource.data);
-
- const registeredOptions = {
- event: options.event.desired,
- dataTypes: options.dataTypes.desired,
- fromSources: options.fromSources?.desired,
- };
-
- const specification: WebhookSpecification = {
- filters: {
- dataTypes: options.dataTypes.desired as WebhookDataType[],
- changeTypes: options.event.desired as WebhookChangeType[],
- fromSources: (options.fromSources?.desired ?? [
- "client",
- "anonymousUser",
- "formSubmission",
- ]) as WebhookFromSource[],
- recordChangeScope: params.tableId,
- },
- };
-
- if (httpSource.active && webhookData.success) {
- const hasMissingOptions = Object.values(options).some(
- (option) => option.missing.length > 0
- );
- if (!hasMissingOptions) return;
-
- const updatedWebhook = await io.integration.webhooks().update("update-webhook", {
- baseId: params.baseId,
- url: httpSource.url,
- webhookId: webhookData.data.id,
- options: specification,
+ crud: {
+ create: async ({ io, ctx }) => {
+ const webhook = await io.integration.webhooks().create("create-webhook", {
+ url: ctx.url,
+ baseId: ctx.params?.baseId,
+ options: getSpecification(ctx.config.desired, ctx.params),
});
- return {
- data: WebhookRegistrationDataSchema.parse(updatedWebhook),
- options: registeredOptions,
- };
- }
+ await io.store.job.set("set-id", "webhook-id", webhook.id);
+ await io.store.job.set("set-secret", "webhook-secret-base64", webhook.macSecretBase64);
+ },
+ read: async ({ io, ctx }) => {
+ const listResponse = await io.integration.webhooks().list("list-webhooks", {
+ baseId: ctx.params?.baseId,
+ });
- const listResponse = await io.integration.webhooks().list("list-webhooks", {
- baseId: params.baseId,
- });
+ const existingWebhook = listResponse.webhooks.find((w) => w.notificationUrl === ctx.url);
- const existingWebhook = listResponse.webhooks.find(
- (w) => w.notificationUrl === httpSource.url
+ if (!existingWebhook) {
+ return await io.store.job.delete("delete-stale-webhook-id", "webhook-id");
+ }
+
+ await io.store.job.set("set-webhook-id", "webhook-id", existingWebhook.id);
+ },
+ delete: async ({ io, ctx }) => {
+ const webhookId = await io.store.job.get("get-webhook-id", "webhook-id");
+
+ await io.integration.webhooks().delete("delete-webhook", {
+ baseId: ctx.params?.baseId,
+ webhookId,
+ });
+ },
+ },
+ verify: async ({ request, client, ctx }) => {
+ // TODO: should pass namespaced store instead, e.g. client.store.webhookRegistration.get()
+ const secretBase64 = await client.store.env.get(
+ `${registerJobNamespace(ctx.key)}:webhook-secret-base64`
);
- if (existingWebhook) {
- const updatedWebhook = await io.integration.webhooks().update("update-webhook", {
- baseId: params.baseId,
- url: httpSource.url,
- webhookId: existingWebhook.id,
- options: specification,
- });
+ return await verifyRequestSignature({
+ request,
+ headerName: "x-airtable-content-mac",
+ secret: Buffer.from(secretBase64, "base64"),
+ algorithm: "sha256",
+ });
+ },
+ generateEvents: async ({ request, client, ctx }) => {
+ console.log("[@trigger.dev/airtable] Handling webhook payload");
- return {
- data: WebhookRegistrationDataSchema.parse(updatedWebhook),
- options: registeredOptions,
- };
+ const webhookPayload = ReceivedPayload.parse(await request.json());
+
+ const webhookId = await client.store.env.get(
+ `${registerJobNamespace(ctx.key)}:webhook-id`
+ );
+
+ const cursorKey = `cursor-${webhookId}`;
+ const cursor = await client.store.env.get(cursorKey);
+
+ // TODO: get auth back
+ const airtable = integration.createClient();
+
+ const response = await getAllPayloads(
+ webhookPayload.base.id,
+ webhookPayload.webhook.id,
+ airtable,
+ cursor
+ );
+
+ if (!response) {
+ return console.log("[@trigger.dev/airtable] No payload fetch response, nothing to do!");
}
- const webhook = await io.integration.webhooks().create("create-webhook", {
- url: httpSource.url,
- baseId: params.baseId,
- options: specification,
- });
+ await client.store.env.set(cursorKey, response.cursor);
- return {
- data: WebhookRegistrationDataSchema.parse(webhook),
- secret: webhook.macSecretBase64,
- options: registeredOptions,
- };
+ const eventsFromResponse = response.payloads.map((payload) => ({
+ id: `${payload.timestamp.getTime()}-${payload.baseTransactionNumber}`,
+ payload,
+ source: "airtable.com",
+ name: "changed",
+ timestamp: payload.timestamp,
+ }));
+
+ await client.sendEvents(eventsFromResponse);
},
});
}
@@ -348,67 +386,6 @@ const ReceivedPayload = z.object({
timestamp: z.coerce.date(),
});
-const SourceMetadataSchema = z
- .object({
- cursor: z.number().optional(),
- })
- .optional();
-
-async function webhookHandler(event: HandlerEvent<"HTTP">, logger: Logger, integration: Airtable) {
- logger.debug("[@trigger.dev/airtable] Handling webhook payload");
-
- const client = integration.createClient(event.source.auth);
-
- const { rawEvent: request, source } = event;
-
- if (!request.body) {
- logger.debug("[@trigger.dev/airtable] No body found");
- return { events: [] };
- }
-
- const rawBody = await request.text();
-
- const signature = request.headers.get("X-Airtable-Content-MAC");
-
- if (!signature) {
- logger.error("[@trigger.dev/airtable] Error validating webhook signature, no signature found");
- throw Error("[@trigger.dev/airtable] No signature found");
- }
-
- const hmac = require("crypto").createHmac("sha256", source.secret);
- hmac.update(rawBody, "ascii");
- const expectedContentHmac = "hmac-sha256=" + hmac.digest("hex");
-
- if (signature !== expectedContentHmac) {
- logger.error("[@trigger.dev/airtable] Error validating webhook signature, they don't match");
- }
-
- const webhookPayload = ReceivedPayload.parse(JSON.parse(rawBody));
- const parsedMetadata = SourceMetadataSchema.parse(source.metadata);
-
- //fetch the actual payloads
- const response = await getAllPayloads(
- webhookPayload.base.id,
- webhookPayload.webhook.id,
- client,
- parsedMetadata?.cursor
- );
-
- return {
- events: response
- ? response.payloads.map((payload) => ({
- id: `${payload.timestamp}-${payload.baseTransactionNumber}`,
- payload: payload,
- source: "airtable.com",
- name: "changed",
- timestamp: payload.timestamp,
- context: {},
- }))
- : [],
- metadata: response?.cursor ? { cursor: response.cursor } : undefined,
- };
-}
-
async function getAllPayloads(
baseId: string,
webhookId: string,
@@ -458,3 +435,21 @@ async function getPayload(
const webhook = await response.json();
return ListWebhooksResponseSchema.parse(webhook);
}
+
+async function handleWebhookError(response: Response, errorType: string) {
+ const rawErrorBody = await response.json();
+
+ const parsedErrorBody = AirtableErrorBodySchema.safeParse(rawErrorBody);
+
+ if (!parsedErrorBody.success) {
+ throw new AirtableApiError(
+ `${errorType}_PARSE_ERROR`,
+ `${response.statusText}:\n${rawErrorBody}`,
+ response.status
+ );
+ }
+
+ const { type, message } = parsedErrorBody.data;
+
+ throw new AirtableApiError(type, message ?? response.statusText, response.status);
+}
diff --git a/integrations/shopify/README.md b/integrations/shopify/README.md
new file mode 100644
index 000000000..5553be780
--- /dev/null
+++ b/integrations/shopify/README.md
@@ -0,0 +1 @@
+# @trigger.dev/shopify
\ No newline at end of file
diff --git a/integrations/shopify/package.json b/integrations/shopify/package.json
new file mode 100644
index 000000000..128aa440d
--- /dev/null
+++ b/integrations/shopify/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@trigger.dev/shopify",
+ "version": "2.2.7",
+ "description": "Trigger.dev integration for @shopify/shopify-api",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "dist/index.js",
+ "dist/index.d.ts",
+ "dist/index.js.map"
+ ],
+ "devDependencies": {
+ "@trigger.dev/tsconfig": "workspace:*",
+ "@trigger.dev/tsup": "workspace:*",
+ "@types/node": "16.x",
+ "rimraf": "^3.0.2",
+ "tsup": "7.1.x",
+ "typescript": "4.9.4"
+ },
+ "scripts": {
+ "clean": "rimraf dist",
+ "build": "npm run clean && npm run build:tsup",
+ "build:tsup": "tsup",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@shopify/shopify-api": "^8.0.2",
+ "@trigger.dev/sdk": "workspace:^2.2.6",
+ "@trigger.dev/integration-kit": "workspace:^2.2.6",
+ "zod": "3.22.3"
+ },
+ "engines": {
+ "node": ">=16.8.0"
+ }
+}
diff --git a/integrations/shopify/src/events.ts b/integrations/shopify/src/events.ts
new file mode 100644
index 000000000..cf919daaf
--- /dev/null
+++ b/integrations/shopify/src/events.ts
@@ -0,0 +1,18 @@
+import { basicProperties, eventSpec } from "./utils";
+import { ShopifyExamples, ShopifyPayloads, shopifyExample } from "./payload-examples";
+import { Nullable } from "@trigger.dev/integration-kit/types";
+import { Prettify } from "@trigger.dev/integration-kit";
+
+type ShopifyThis = Prettify<
+ Nullable & {
+ [key: string]: any;
+ }
+>;
+
+export const shopifyEvent = [0]>(topic: TTopic) => {
+ return eventSpec>({
+ topic,
+ examples: [shopifyExample(topic)],
+ runProperties: (payload) => basicProperties(payload),
+ });
+};
diff --git a/integrations/shopify/src/index.ts b/integrations/shopify/src/index.ts
new file mode 100644
index 000000000..f0e94eb3a
--- /dev/null
+++ b/integrations/shopify/src/index.ts
@@ -0,0 +1,238 @@
+import {
+ TriggerIntegration,
+ RunTaskOptions,
+ IO,
+ IOTask,
+ IntegrationTaskKey,
+ RunTaskErrorCallback,
+ Json,
+ retry,
+ ConnectionAuth,
+} from "@trigger.dev/sdk";
+import { OmitIndexSignature } from "@trigger.dev/integration-kit/types";
+
+import {
+ ApiVersion,
+ HttpRetriableError,
+ HttpThrottlingError,
+ LATEST_API_VERSION,
+ LogSeverity,
+ Session,
+ shopifyApi,
+ ShopifyError,
+} from "@shopify/shopify-api";
+
+// this has to be updated manually with each LATEST_API_VERSION bump
+import { restResources, type RestResources } from "@shopify/shopify-api/rest/admin/2023-10";
+import "@shopify/shopify-api/adapters/node";
+
+import { ApiScope } from "./schemas";
+import { createWebhookEventCatalog, WebhookEventCatalog } from "./triggers";
+import { Webhooks, createWebhookEventSource } from "./webhooks";
+import { Rest, restProxy } from "./rest";
+import { GetWebhookParams } from "@trigger.dev/sdk/triggers/webhook";
+
+export type ShopifyRestResources = OmitIndexSignature;
+
+export type ShopifyIntegrationOptions = {
+ id: string;
+ apiKey: string;
+ apiSecretKey: string;
+ apiVersion?: ApiVersion;
+ adminAccessToken: string;
+ hostName: string;
+ restResources?: RestResources;
+ scopes?: ApiScope[];
+ session?: Session;
+};
+
+export type ShopifyRunTask = InstanceType["runTask"];
+
+type EventNamesFromCatalog> =
+ TEventCatalog extends WebhookEventCatalog ? keyof U : never;
+
+export class Shopify implements TriggerIntegration {
+ private _options: ShopifyIntegrationOptions;
+
+ private _client?: ReturnType<(typeof this)["createClient"]>;
+ private _io?: IO;
+ private _connectionKey?: string;
+ private _session?: Session;
+ private _shopDomain: string;
+
+ constructor(private options: ShopifyIntegrationOptions) {
+ if (Object.keys(options).includes("apiKey") && !options.apiKey) {
+ throw `Can't create Shopify integration (${options.id}) as apiKey was undefined`;
+ }
+
+ this._options = options;
+ this._shopDomain = this._options.hostName.replace("http://", "").replace("https://", "");
+ }
+
+ get authSource() {
+ return this._options.apiKey ? "LOCAL" : "HOSTED";
+ }
+
+ get id() {
+ return this.options.id;
+ }
+
+ get metadata() {
+ return { id: "shopify", name: "Shopify" };
+ }
+
+ get #source() {
+ return createWebhookEventSource(this);
+ }
+
+ get #eventCatalog() {
+ return createWebhookEventCatalog(this.#source);
+ }
+
+ cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) {
+ const shopify = new Shopify(this._options);
+
+ const client = this.createClient(auth);
+
+ const session = client.session.customAppSession(client.config.hostName);
+ session.accessToken = client.config.adminApiAccessToken;
+
+ shopify._io = io;
+ shopify._connectionKey = connectionKey;
+ shopify._client = client;
+ shopify._session = this._options.session ?? session;
+
+ return shopify;
+ }
+
+ createClient(auth?: ConnectionAuth) {
+ // oauth
+ // if (auth) {
+ // return shopifyApi({
+ // apiKey: this._options.apiKey,
+ // apiSecretKey: auth.accessToken,
+ // adminApiAccessToken: this._options.adminAccessToken,
+ // apiVersion: this._options.apiVersion ?? LATEST_API_VERSION,
+ // hostName: this._shopDomain,
+ // scopes: auth.scopes,
+ // restResources: this._options.restResources ?? restResources,
+ // isCustomStoreApp: false,
+ // isEmbeddedApp: true,
+ // logger: {
+ // level: LogSeverity.Warning,
+ // },
+ // });
+ // }
+
+ // apiKey auth
+ if (this._options.apiKey) {
+ return shopifyApi({
+ apiKey: this._options.apiKey,
+ apiSecretKey: this._options.apiSecretKey,
+ adminApiAccessToken: this._options.adminAccessToken,
+ apiVersion: this._options.apiVersion ?? LATEST_API_VERSION,
+ hostName: this._shopDomain,
+ scopes: this._options.scopes,
+ restResources: this._options.restResources ?? restResources,
+ isCustomStoreApp: true,
+ isEmbeddedApp: false,
+ logger: {
+ level: LogSeverity.Warning,
+ },
+ });
+ }
+
+ throw new Error("No auth");
+ }
+
+ runTask | void>(
+ key: IntegrationTaskKey,
+ callback: (
+ client: ReturnType,
+ task: IOTask,
+ io: IO,
+ session: Session
+ ) => Promise,
+ options?: RunTaskOptions,
+ errorCallback?: RunTaskErrorCallback
+ ): Promise {
+ if (!this._io) throw new Error("No IO");
+ if (!this._connectionKey) throw new Error("No connection key");
+
+ return this._io.runTask(
+ key,
+ (task, io) => {
+ if (!this._client) throw new Error("No client");
+ if (!this._session) throw new Error("No session");
+ return callback(this._client, task, io, this._session);
+ },
+ {
+ icon: "shopify",
+ retry: retry.standardBackoff,
+ ...(options ?? {}),
+ connectionKey: this._connectionKey,
+ },
+ errorCallback ?? onError
+ );
+ }
+
+ /**
+ * Creates a webhook trigger.
+ */
+ on>>(
+ name: TName
+ // additional params have been disabled, see WebhookSource schema
+ // params?: Omit>, "topic">
+ ) {
+ return this.#eventCatalog.on(name, { topic: name });
+ }
+
+ get #webhooks() {
+ return new Webhooks(this.runTask.bind(this));
+ }
+
+ get rest() {
+ if (!this._session) {
+ throw new Error("No session");
+ }
+
+ return restProxy(
+ new Rest(this.runTask.bind(this), this._session),
+ this._session,
+ this.runTask.bind(this)
+ );
+ }
+}
+
+export function onError(error: unknown): ReturnType {
+ if (!(error instanceof ShopifyError)) {
+ return;
+ }
+
+ if (!(error instanceof HttpRetriableError)) {
+ return {
+ skipRetrying: true,
+ };
+ }
+
+ if (!(error instanceof HttpThrottlingError)) {
+ return;
+ }
+
+ const retryAfter = error.response.retryAfter;
+
+ if (retryAfter) {
+ const retryAfterMs = Number(retryAfter) * 1000;
+
+ if (Number.isNaN(retryAfterMs)) {
+ return;
+ }
+
+ const resetDate = new Date(Date.now() + retryAfterMs);
+
+ return {
+ retryAt: resetDate,
+ error,
+ };
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json b/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json
new file mode 100644
index 000000000..7db2507e0
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json
@@ -0,0 +1,12 @@
+{
+ "app_subscription": {
+ "admin_graphql_api_id": "gid://shopify/AppSubscription/1029266999",
+ "name": "Webhook Test",
+ "status": "PENDING",
+ "admin_graphql_api_shop_id": "gid://shopify/Shop/548380009",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "currency": "USD",
+ "capped_amount": "20.0"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/AppUninstalled.json b/integrations/shopify/src/payload-examples/AppUninstalled.json
new file mode 100644
index 000000000..04a25b206
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/AppUninstalled.json
@@ -0,0 +1,56 @@
+{
+ "id": 548380009,
+ "name": "Super Toys",
+ "email": "super@supertoys.com",
+ "domain": null,
+ "province": "Tennessee",
+ "country": "US",
+ "address1": "190 MacLaren Street",
+ "zip": "37178",
+ "city": "Houston",
+ "source": null,
+ "phone": "3213213210",
+ "latitude": null,
+ "longitude": null,
+ "primary_locale": "en",
+ "address2": null,
+ "created_at": null,
+ "updated_at": null,
+ "country_code": "US",
+ "country_name": "United States",
+ "currency": "USD",
+ "customer_email": "super@supertoys.com",
+ "timezone": "(GMT-05:00) Eastern Time (US & Canada)",
+ "iana_timezone": null,
+ "shop_owner": "John Smith",
+ "money_format": "${{amount}}",
+ "money_with_currency_format": "${{amount}} USD",
+ "weight_unit": "kg",
+ "province_code": "TN",
+ "taxes_included": null,
+ "auto_configure_tax_inclusivity": null,
+ "tax_shipping": null,
+ "county_taxes": null,
+ "plan_display_name": "Shopify Plus",
+ "plan_name": "enterprise",
+ "has_discounts": false,
+ "has_gift_cards": true,
+ "myshopify_domain": null,
+ "google_apps_domain": null,
+ "google_apps_login_enabled": null,
+ "money_in_emails_format": "${{amount}}",
+ "money_with_currency_in_emails_format": "${{amount}} USD",
+ "eligible_for_payments": true,
+ "requires_extra_payments_agreement": false,
+ "password_enabled": null,
+ "has_storefront": true,
+ "finances": true,
+ "primary_location_id": 655441491,
+ "checkout_api_supported": true,
+ "multi_location_enabled": true,
+ "setup_required": false,
+ "pre_launch_enabled": false,
+ "enabled_presentment_currencies": ["USD"],
+ "transactional_sms_disabled": false,
+ "marketing_sms_consent_enabled_at_checkout": false
+}
diff --git a/integrations/shopify/src/payload-examples/BulkOperationsFinish.json b/integrations/shopify/src/payload-examples/BulkOperationsFinish.json
new file mode 100644
index 000000000..af7dd7a36
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/BulkOperationsFinish.json
@@ -0,0 +1,8 @@
+{
+ "admin_graphql_api_id": "gid://shopify/BulkOperation/147595010",
+ "completed_at": "2023-10-10T11:30:21-04:00",
+ "created_at": "2023-10-10T11:30:21-04:00",
+ "error_code": null,
+ "status": "completed",
+ "type": "query"
+}
diff --git a/integrations/shopify/src/payload-examples/CartsCreate.json b/integrations/shopify/src/payload-examples/CartsCreate.json
new file mode 100644
index 000000000..ab96fe08b
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CartsCreate.json
@@ -0,0 +1,80 @@
+{
+ "id": "eeafa272cebfd4b22385bc4b645e762c",
+ "token": "eeafa272cebfd4b22385bc4b645e762c",
+ "line_items": [
+ {
+ "id": 704912205188288500,
+ "properties": {},
+ "quantity": 3,
+ "variant_id": 704912205188288500,
+ "key": "704912205188288575:33f11f7a1ec7d93b826de33bb54de37b",
+ "discounted_price": "19.99",
+ "discounts": [],
+ "gift_card": false,
+ "grams": 200,
+ "line_price": "59.97",
+ "original_line_price": "59.97",
+ "original_price": "19.99",
+ "price": "19.99",
+ "product_id": 788032119674292900,
+ "sku": "example-shirt-s",
+ "taxable": true,
+ "title": "Example T-Shirt",
+ "total_discount": "0.00",
+ "vendor": "Acme",
+ "discounted_price_set": {
+ "shop_money": {
+ "amount": "19.99",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "19.99",
+ "currency_code": "USD"
+ }
+ },
+ "line_price_set": {
+ "shop_money": {
+ "amount": "59.97",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "59.97",
+ "currency_code": "USD"
+ }
+ },
+ "original_line_price_set": {
+ "shop_money": {
+ "amount": "59.97",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "59.97",
+ "currency_code": "USD"
+ }
+ },
+ "price_set": {
+ "shop_money": {
+ "amount": "19.99",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "19.99",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.0",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.0",
+ "currency_code": "USD"
+ }
+ }
+ }
+ ],
+ "note": null,
+ "updated_at": "2022-01-01T00:00:00.000Z",
+ "created_at": "2022-01-01T00:00:00.000Z"
+}
diff --git a/integrations/shopify/src/payload-examples/CheckoutsCreate.json b/integrations/shopify/src/payload-examples/CheckoutsCreate.json
new file mode 100644
index 000000000..9d7376c6d
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CheckoutsCreate.json
@@ -0,0 +1,188 @@
+{
+ "id": 981820079255243500,
+ "token": "123123123",
+ "cart_token": "eeafa272cebfd4b22385bc4b645e762c",
+ "email": "example@email.com",
+ "gateway": null,
+ "buyer_accepts_marketing": false,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "landing_site": null,
+ "note": null,
+ "note_attributes": [],
+ "referring_site": null,
+ "shipping_lines": [],
+ "taxes_included": false,
+ "total_weight": 907,
+ "currency": "USD",
+ "completed_at": null,
+ "closed_at": null,
+ "user_id": null,
+ "location_id": null,
+ "source_identifier": null,
+ "source_url": null,
+ "device_id": null,
+ "phone": null,
+ "customer_locale": null,
+ "line_items": [
+ {
+ "applied_discounts": [],
+ "discount_allocations": [],
+ "key": "ae31d3cc0703817acfd14bfc2ddca48b",
+ "destination_location_id": 938998353,
+ "fulfillment_service": "manual",
+ "gift_card": false,
+ "grams": 454,
+ "origin_location_id": 938998352,
+ "presentment_title": "IPod Nano - 8GB",
+ "presentment_variant_title": "",
+ "product_id": 632910392,
+ "properties": null,
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "tax_lines": [],
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "variant_id": null,
+ "variant_title": "",
+ "variant_price": null,
+ "vendor": "Apple",
+ "user_id": null,
+ "unit_price_measurement": null,
+ "rank": null,
+ "compare_at_price": null,
+ "line_price": "199.00",
+ "price": "199.00"
+ },
+ {
+ "applied_discounts": [],
+ "discount_allocations": [],
+ "key": "ae31d3cc0703817acfd14bfc2ddca48b",
+ "destination_location_id": 938998353,
+ "fulfillment_service": "manual",
+ "gift_card": false,
+ "grams": 454,
+ "origin_location_id": 938998352,
+ "presentment_title": "IPod Nano - 8GB",
+ "presentment_variant_title": "",
+ "product_id": 632910392,
+ "properties": null,
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "tax_lines": [],
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "variant_id": null,
+ "variant_title": "",
+ "variant_price": null,
+ "vendor": "Apple",
+ "user_id": null,
+ "unit_price_measurement": null,
+ "rank": null,
+ "compare_at_price": null,
+ "line_price": "199.00",
+ "price": "199.00"
+ }
+ ],
+ "name": "#981820079255243537",
+ "source": null,
+ "abandoned_checkout_url": "https://checkout.local/548380009/checkouts/123123123/recover?key=example-secret-token",
+ "discount_codes": [],
+ "tax_lines": [],
+ "source_name": "web",
+ "presentment_currency": "USD",
+ "buyer_accepts_sms_marketing": false,
+ "sms_marketing_phone": null,
+ "total_discounts": "0.00",
+ "total_line_items_price": "398.00",
+ "total_price": "398.00",
+ "total_tax": "0.00",
+ "subtotal_price": "398.00",
+ "total_duties": null,
+ "billing_address": {
+ "first_name": "Bob",
+ "address1": "123 Billing Street",
+ "phone": "555-555-BILL",
+ "city": "Billtown",
+ "zip": "K2P0B0",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Biller",
+ "address2": null,
+ "company": "My Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Bob Biller",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "shipping_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "K2P0S0",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "customer": {
+ "id": 603851970716743400,
+ "email": "john@example.com",
+ "accepts_marketing": false,
+ "created_at": null,
+ "updated_at": null,
+ "first_name": "John",
+ "last_name": "Smith",
+ "orders_count": 0,
+ "state": "disabled",
+ "total_spent": "0.00",
+ "last_order_id": null,
+ "note": null,
+ "verified_email": true,
+ "multipass_identifier": null,
+ "tax_exempt": false,
+ "tags": "",
+ "last_order_name": null,
+ "currency": "USD",
+ "phone": null,
+ "accepts_marketing_updated_at": null,
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "email_marketing_consent": {
+ "state": "not_subscribed",
+ "opt_in_level": null,
+ "consent_updated_at": null
+ },
+ "sms_marketing_consent": null,
+ "admin_graphql_api_id": "gid://shopify/Customer/603851970716743426",
+ "default_address": {
+ "id": null,
+ "customer_id": 603851970716743400,
+ "first_name": null,
+ "last_name": null,
+ "company": null,
+ "address1": "123 Elm St.",
+ "address2": null,
+ "city": "Ottawa",
+ "province": "Ontario",
+ "country": "Canada",
+ "zip": "K2H7A8",
+ "phone": "123-123-1234",
+ "name": "",
+ "province_code": "ON",
+ "country_code": "CA",
+ "country_name": "Canada",
+ "default": true
+ }
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CheckoutsDelete.json b/integrations/shopify/src/payload-examples/CheckoutsDelete.json
new file mode 100644
index 000000000..5d80d4bd6
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CheckoutsDelete.json
@@ -0,0 +1,12 @@
+{
+ "id": 981820079255243500,
+ "presentment_currency": "USD",
+ "buyer_accepts_sms_marketing": false,
+ "sms_marketing_phone": null,
+ "total_discounts": "0.00",
+ "total_line_items_price": "398.00",
+ "total_price": "398.00",
+ "total_tax": "0.00",
+ "subtotal_price": "398.00",
+ "total_duties": null
+}
diff --git a/integrations/shopify/src/payload-examples/CollectionListingsAdd.json b/integrations/shopify/src/payload-examples/CollectionListingsAdd.json
new file mode 100644
index 000000000..9da3913d2
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CollectionListingsAdd.json
@@ -0,0 +1,13 @@
+{
+ "collection_listing": {
+ "collection_id": 408372092144951400,
+ "updated_at": null,
+ "body_html": "Some HTML",
+ "default_product_image": null,
+ "handle": "mynewcollection",
+ "image": null,
+ "title": "My New Collection",
+ "sort_order": null,
+ "published_at": "2021-12-31T19:00:00-05:00"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CollectionListingsRemove.json b/integrations/shopify/src/payload-examples/CollectionListingsRemove.json
new file mode 100644
index 000000000..b91509459
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CollectionListingsRemove.json
@@ -0,0 +1,5 @@
+{
+ "collection_listing": {
+ "collection_id": 408372092144951400
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CollectionsCreate.json b/integrations/shopify/src/payload-examples/CollectionsCreate.json
new file mode 100644
index 000000000..94c175f71
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CollectionsCreate.json
@@ -0,0 +1,12 @@
+{
+ "id": 408372092144951400,
+ "handle": "mynewcollection",
+ "title": "My New Collection",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "body_html": "Some HTML",
+ "published_at": "2021-12-31T16:00:00-05:00",
+ "sort_order": null,
+ "template_suffix": null,
+ "published_scope": "web",
+ "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419"
+}
diff --git a/integrations/shopify/src/payload-examples/CollectionsDelete.json b/integrations/shopify/src/payload-examples/CollectionsDelete.json
new file mode 100644
index 000000000..f7d859015
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CollectionsDelete.json
@@ -0,0 +1,5 @@
+{
+ "id": 408372092144951400,
+ "published_scope": "web",
+ "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419"
+}
diff --git a/integrations/shopify/src/payload-examples/CompaniesCreate.json b/integrations/shopify/src/payload-examples/CompaniesCreate.json
new file mode 100644
index 000000000..f9314324e
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CompaniesCreate.json
@@ -0,0 +1,10 @@
+{
+ "name": "Example Company",
+ "note": "This is an example company",
+ "external_id": "123456789",
+ "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "customer_since": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/Company/408372092144951419"
+}
diff --git a/integrations/shopify/src/payload-examples/CompanyContactsCreate.json b/integrations/shopify/src/payload-examples/CompanyContactsCreate.json
new file mode 100644
index 000000000..dca8c6497
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CompanyContactsCreate.json
@@ -0,0 +1,18 @@
+{
+ "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391",
+ "title": "Buyer",
+ "locale": "en",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419",
+ "company": {
+ "name": "Example Company",
+ "note": "This is an example company",
+ "external_id": "123456789",
+ "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "customer_since": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/Company/408372092144951419"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json b/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json
new file mode 100644
index 000000000..17e1f8290
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json
@@ -0,0 +1,57 @@
+{
+ "name": "Montreal",
+ "external_id": "123456789",
+ "phone": "555-555-5555",
+ "locale": "en",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "note": "Head Office Location",
+ "buyer_experience_configuration": null,
+ "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419",
+ "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"],
+ "company": {
+ "name": "Example Company",
+ "note": "This is an example company",
+ "external_id": "123456789",
+ "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "customer_since": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/Company/408372092144951419"
+ },
+ "billing_address": {
+ "address1": "175 Sherbrooke Street West",
+ "city": "Montreal",
+ "province": "Quebec",
+ "country": "Canada",
+ "zip": "H3A 0G4",
+ "recipient": "Adam Felix",
+ "address2": null,
+ "phone": "+49738001239",
+ "zone_code": "QC",
+ "country_code": "CA",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115",
+ "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419"
+ },
+ "shipping_address": {
+ "address1": "175 Sherbrooke Street West",
+ "city": "Montreal",
+ "province": "Quebec",
+ "country": "Canada",
+ "zip": "H3A 0G4",
+ "recipient": "Adam Felix",
+ "address2": null,
+ "phone": "+49738001239",
+ "zone_code": "QC",
+ "country_code": "CA",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115",
+ "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419"
+ },
+ "tax_registration": {
+ "tax_id": "1214214141"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json b/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json
new file mode 100644
index 000000000..128ef9d69
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json
@@ -0,0 +1,7 @@
+{
+ "id": 239443597569284770,
+ "name": "Repeat Customers",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "query": "orders_count:>1"
+}
diff --git a/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json b/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json
new file mode 100644
index 000000000..8ef7560aa
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json
@@ -0,0 +1,14 @@
+{
+ "admin_graphql_api_id": "gid://shopify/CustomerPaymentMethod/0eccccc666aac73efcd31094ddc4ebf0",
+ "token": "0eccccc666aac73efcd31094ddc4ebf0",
+ "customer_id": 82850125,
+ "admin_graphql_api_customer_id": "gid://shopify/Customer/82850125",
+ "instrument_type": "CustomerCreditCard",
+ "payment_instrument": {
+ "last_digits": "4242",
+ "month": 8,
+ "year": 2060,
+ "name": "Jim Smith",
+ "brand": "Visa"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CustomersCreate.json b/integrations/shopify/src/payload-examples/CustomersCreate.json
new file mode 100644
index 000000000..2c36ced46
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomersCreate.json
@@ -0,0 +1,28 @@
+{
+ "id": 706405506930370000,
+ "email": "bob@biller.com",
+ "accepts_marketing": true,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "first_name": "Bob",
+ "last_name": "Biller",
+ "orders_count": 0,
+ "state": "disabled",
+ "total_spent": "0.00",
+ "last_order_id": null,
+ "note": "This customer loves ice cream",
+ "verified_email": true,
+ "multipass_identifier": null,
+ "tax_exempt": false,
+ "tags": "",
+ "last_order_name": null,
+ "currency": "USD",
+ "phone": null,
+ "addresses": [],
+ "accepts_marketing_updated_at": "2021-12-31T19:00:00-05:00",
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "email_marketing_consent": null,
+ "sms_marketing_consent": null,
+ "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084"
+}
diff --git a/integrations/shopify/src/payload-examples/CustomersDelete.json b/integrations/shopify/src/payload-examples/CustomersDelete.json
new file mode 100644
index 000000000..11011e812
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomersDelete.json
@@ -0,0 +1,11 @@
+{
+ "id": 706405506930370000,
+ "phone": null,
+ "addresses": [],
+ "accepts_marketing_updated_at": "2021-12-31T19:00:00-05:00",
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "email_marketing_consent": null,
+ "sms_marketing_consent": null,
+ "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084"
+}
diff --git a/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json b/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json
new file mode 100644
index 000000000..eb654cb36
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json
@@ -0,0 +1,9 @@
+{
+ "customer_id": 706405506930370000,
+ "email_address": null,
+ "email_marketing_consent": {
+ "state": null,
+ "opt_in_level": null,
+ "consent_updated_at": null
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json b/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json
new file mode 100644
index 000000000..929756adf
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json
@@ -0,0 +1,10 @@
+{
+ "id": 706405506930370000,
+ "phone": null,
+ "sms_marketing_consent": {
+ "state": null,
+ "opt_in_level": null,
+ "consent_updated_at": null,
+ "consent_collected_from": "other"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/CustomersMerge.json b/integrations/shopify/src/payload-examples/CustomersMerge.json
new file mode 100644
index 000000000..9a0a6c4f5
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/CustomersMerge.json
@@ -0,0 +1,13 @@
+{
+ "admin_graphql_api_customer_kept_id": "gid://shopify/Customer/1",
+ "admin_graphql_api_customer_deleted_id": "gid://shopify/Customer/2",
+ "admin_graphql_api_job_id": null,
+ "status": "failed",
+ "errors": [
+ {
+ "customer_ids": [1],
+ "field": "merge_in_progress",
+ "message": "John Doe is currently being merged."
+ }
+ ]
+}
diff --git a/integrations/shopify/src/payload-examples/Deleted.json b/integrations/shopify/src/payload-examples/Deleted.json
new file mode 100644
index 000000000..51b90fbe7
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Deleted.json
@@ -0,0 +1,3 @@
+{
+ "id": 788032119674292900
+}
diff --git a/integrations/shopify/src/payload-examples/DeliveryProfile.json b/integrations/shopify/src/payload-examples/DeliveryProfile.json
new file mode 100644
index 000000000..2572ae5fe
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/DeliveryProfile.json
@@ -0,0 +1,3 @@
+{
+ "id": 1
+}
diff --git a/integrations/shopify/src/payload-examples/DisputesCreate.json b/integrations/shopify/src/payload-examples/DisputesCreate.json
new file mode 100644
index 000000000..158b2ba54
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/DisputesCreate.json
@@ -0,0 +1,14 @@
+{
+ "id": 285332461850802050,
+ "order_id": 820982911946154500,
+ "type": "chargeback",
+ "amount": "11.50",
+ "currency": "CAD",
+ "reason": "fraudulent",
+ "network_reason_code": "4837",
+ "status": "under_review",
+ "evidence_due_by": "2021-12-30T19:00:00-05:00",
+ "evidence_sent_on": null,
+ "finalized_on": null,
+ "initiated_at": "2021-12-31T19:00:00-05:00"
+}
diff --git a/integrations/shopify/src/payload-examples/DomainsCreate.json b/integrations/shopify/src/payload-examples/DomainsCreate.json
new file mode 100644
index 000000000..77de13505
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/DomainsCreate.json
@@ -0,0 +1,10 @@
+{
+ "id": 690933842,
+ "host": "jsmith.myshopify.com",
+ "ssl_enabled": true,
+ "localization": {
+ "country": null,
+ "default_locale": "en",
+ "alternate_locales": []
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/DraftOrdersCreate.json b/integrations/shopify/src/payload-examples/DraftOrdersCreate.json
new file mode 100644
index 000000000..bb7967a1e
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/DraftOrdersCreate.json
@@ -0,0 +1,231 @@
+{
+ "id": 890612572568261600,
+ "note": null,
+ "email": "jon@doe.ca",
+ "taxes_included": false,
+ "currency": "USD",
+ "invoice_sent_at": null,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "tax_exempt": false,
+ "completed_at": null,
+ "name": "#D234",
+ "status": "open",
+ "line_items": [
+ {
+ "id": 994118531,
+ "variant_id": 808950810,
+ "product_id": 632910392,
+ "title": "IPod Nano - 8GB",
+ "variant_title": "Pink",
+ "sku": "IPOD2008PINK",
+ "vendor": "Apple",
+ "quantity": 3,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "fulfillment_service": "manual",
+ "grams": 567,
+ "tax_lines": [],
+ "applied_discount": null,
+ "name": "IPod Nano - 8GB - Pink",
+ "properties": [],
+ "custom": false,
+ "price": "199.00",
+ "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118531"
+ },
+ {
+ "id": 994118533,
+ "variant_id": 808950810,
+ "product_id": 632910392,
+ "title": "IPod Nano - 8GB",
+ "variant_title": "Pink",
+ "sku": "IPOD2008PINK",
+ "vendor": "Apple",
+ "quantity": 1,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "fulfillment_service": "manual",
+ "grams": 567,
+ "tax_lines": [],
+ "applied_discount": null,
+ "name": "IPod Nano - 8GB - Pink",
+ "properties": [],
+ "custom": false,
+ "price": "199.00",
+ "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118533"
+ },
+ {
+ "id": 994118535,
+ "variant_id": 457924702,
+ "product_id": 632910392,
+ "title": "IPod Nano - 8GB",
+ "variant_title": "Black",
+ "sku": "IPOD2008BLACK",
+ "vendor": "Apple",
+ "quantity": 10,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "fulfillment_service": "manual",
+ "grams": 567,
+ "tax_lines": [],
+ "applied_discount": {
+ "description": "bulk discount",
+ "value": "10.0",
+ "title": "Bulk Discount",
+ "amount": "199.00",
+ "value_type": "percentage"
+ },
+ "name": "IPod Nano - 8GB - Black",
+ "properties": [],
+ "custom": false,
+ "price": "199.00",
+ "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118535"
+ }
+ ],
+ "shipping_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40150",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "billing_address": {
+ "first_name": "Bob",
+ "address1": "123 Billing Street",
+ "phone": "555-555-BILL",
+ "city": "Billtown",
+ "zip": "K2P0B0",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Biller",
+ "address2": null,
+ "company": "My Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Bob Biller",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "invoice_url": "https://jsmith.myshopify.com/548380009/invoices/abcd1234abcd1234abcd1234abcd1234",
+ "applied_discount": {
+ "description": "loyalty",
+ "value": "50.0",
+ "title": "Loyalty",
+ "amount": "50.00",
+ "value_type": "fixed_amount"
+ },
+ "order_id": null,
+ "shipping_line": {
+ "title": "Generic Shipping",
+ "custom": true,
+ "handle": null,
+ "price": "10.00"
+ },
+ "tax_lines": [
+ {
+ "rate": 0.06,
+ "title": "State tax",
+ "price": "35.82"
+ },
+ {
+ "rate": 0.06,
+ "title": "State tax",
+ "price": "11.94"
+ },
+ {
+ "rate": 0.06,
+ "title": "State tax",
+ "price": "107.46"
+ }
+ ],
+ "tags": "",
+ "note_attributes": [],
+ "total_price": "2702.22",
+ "subtotal_price": "2537.00",
+ "total_tax": "0.00",
+ "payment_terms": {
+ "id": 706405506930370000,
+ "payment_terms_name": "Net 7",
+ "payment_terms_type": "net",
+ "due_in_days": 7,
+ "created_at": "2021-01-01T00:00:00-05:00",
+ "updated_at": "2021-01-01T00:00:01-05:00",
+ "payment_schedules": [
+ {
+ "id": 606405506930370000,
+ "created_at": "2021-01-01T00:00:00-05:00",
+ "updated_at": "2021-01-01T00:00:01-05:00",
+ "payment_terms_id": 706405506930370000,
+ "issued_at": "2021-01-01T00:00:00-05:00",
+ "due_at": "2021-01-02T00:00:00-05:00",
+ "completed_at": "2021-01-02T00:00:00-05:00",
+ "amount": "10.00",
+ "currency": "USD"
+ }
+ ]
+ },
+ "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625",
+ "customer": {
+ "id": 706405506930370000,
+ "email": "john@doe.ca",
+ "accepts_marketing": false,
+ "created_at": null,
+ "updated_at": null,
+ "first_name": "John",
+ "last_name": "Smith",
+ "orders_count": 0,
+ "state": "disabled",
+ "total_spent": "0.00",
+ "last_order_id": null,
+ "note": null,
+ "verified_email": true,
+ "multipass_identifier": null,
+ "tax_exempt": false,
+ "tags": "",
+ "last_order_name": null,
+ "currency": "USD",
+ "phone": null,
+ "accepts_marketing_updated_at": null,
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "email_marketing_consent": {
+ "state": "not_subscribed",
+ "opt_in_level": null,
+ "consent_updated_at": null
+ },
+ "sms_marketing_consent": null,
+ "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084",
+ "default_address": {
+ "id": null,
+ "customer_id": 706405506930370000,
+ "first_name": null,
+ "last_name": null,
+ "company": null,
+ "address1": "123 Elm St.",
+ "address2": null,
+ "city": "Ottawa",
+ "province": "Ontario",
+ "country": "Canada",
+ "zip": "K2H7A8",
+ "phone": "123-123-1234",
+ "name": "",
+ "province_code": "ON",
+ "country_code": "CA",
+ "country_name": "Canada",
+ "default": true
+ }
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/DraftOrdersDelete.json b/integrations/shopify/src/payload-examples/DraftOrdersDelete.json
new file mode 100644
index 000000000..fbfb96a34
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/DraftOrdersDelete.json
@@ -0,0 +1,25 @@
+{
+ "id": 890612572568261600,
+ "payment_terms": {
+ "id": 706405506930370000,
+ "payment_terms_name": "Net 7",
+ "payment_terms_type": "net",
+ "due_in_days": 7,
+ "created_at": "2021-01-01T00:00:00-05:00",
+ "updated_at": "2021-01-01T00:00:01-05:00",
+ "payment_schedules": [
+ {
+ "id": 606405506930370000,
+ "created_at": "2021-01-01T00:00:00-05:00",
+ "updated_at": "2021-01-01T00:00:01-05:00",
+ "payment_terms_id": 706405506930370000,
+ "issued_at": "2021-01-01T00:00:00-05:00",
+ "due_at": "2021-01-02T00:00:00-05:00",
+ "completed_at": "2021-01-02T00:00:00-05:00",
+ "amount": "10.00",
+ "currency": "USD"
+ }
+ ]
+ },
+ "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625"
+}
diff --git a/integrations/shopify/src/payload-examples/Fulfillment.json b/integrations/shopify/src/payload-examples/Fulfillment.json
new file mode 100644
index 000000000..1954e5e70
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Fulfillment.json
@@ -0,0 +1,164 @@
+{
+ "id": 123456,
+ "order_id": 820982911946154500,
+ "status": "pending",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "service": null,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "tracking_company": "UPS",
+ "shipment_status": null,
+ "location_id": null,
+ "origin_address": null,
+ "email": "jon@example.com",
+ "destination": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "line_items": [
+ {
+ "id": 866550311766439000,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "0.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020",
+ "tax_lines": []
+ },
+ {
+ "id": 141249953214522980,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "5.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [
+ {
+ "amount": "5.00",
+ "discount_application_index": 0,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ },
+ {
+ "amount": "5.00",
+ "discount_application_index": 2,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ }
+ ],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974",
+ "tax_lines": []
+ }
+ ],
+ "tracking_number": "1z827wk74630",
+ "tracking_numbers": ["1z827wk74630"],
+ "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630",
+ "tracking_urls": [
+ "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630"
+ ],
+ "receipt": {},
+ "name": "#9999.1",
+ "admin_graphql_api_id": "gid://shopify/Fulfillment/123456"
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json b/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json
new file mode 100644
index 000000000..fcc207fe8
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json
@@ -0,0 +1,20 @@
+{
+ "id": 1234567,
+ "fulfillment_id": 123456,
+ "status": "in_transit",
+ "message": "Item is now in transit",
+ "happened_at": "2021-12-31T19:00:00-05:00",
+ "city": null,
+ "province": null,
+ "country": "CA",
+ "zip": null,
+ "address1": null,
+ "latitude": null,
+ "longitude": null,
+ "shop_id": 548380009,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "estimated_delivery_at": null,
+ "order_id": 820982911946154500,
+ "admin_graphql_api_id": "gid://shopify/FulfillmentEvent/1234567"
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json
new file mode 100644
index 000000000..a972b443d
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json
@@ -0,0 +1,7 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "closed"
+ },
+ "message": "Order has not been shipped yet."
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json
new file mode 100644
index 000000000..88d8781da
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json
@@ -0,0 +1,8 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "in_progress",
+ "request_status": "cancellation_rejected"
+ },
+ "message": "Order has already been shipped."
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json
new file mode 100644
index 000000000..1d61a3bf5
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json
@@ -0,0 +1,11 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "in_progress",
+ "request_status": "cancellation_request"
+ },
+ "fulfillment_order_merchant_request": {
+ "id": "gid://shopify/FulfillmentOrderMerchantRequest/1",
+ "message": "Customer cancelled their order"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json
new file mode 100644
index 000000000..43d0819ea
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json
@@ -0,0 +1,10 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "cancelled"
+ },
+ "replacement_fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/2",
+ "status": "open"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json
new file mode 100644
index 000000000..75557c0c8
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json
@@ -0,0 +1,8 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "in_progress",
+ "request_status": "accepted"
+ },
+ "message": "We will ship the item tomorrow."
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json
new file mode 100644
index 000000000..2e94513cd
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json
@@ -0,0 +1,8 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open",
+ "request_status": "rejected"
+ },
+ "message": "Can't fulfill due to no inventory on product."
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json
new file mode 100644
index 000000000..98b35320d
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json
@@ -0,0 +1,16 @@
+{
+ "original_fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open",
+ "request_status": "unsubmitted"
+ },
+ "submitted_fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open",
+ "request_status": "unsubmitted"
+ },
+ "fulfillment_order_merchant_request": {
+ "id": "gid://shopify/FulfillmentOrderMerchantRequest/1",
+ "message": "Fragile"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json
new file mode 100644
index 000000000..97b32f2f7
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json
@@ -0,0 +1,7 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "closed"
+ },
+ "message": "We broke the last item."
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json
new file mode 100644
index 000000000..ca9ffb6dd
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json
@@ -0,0 +1,6 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json
new file mode 100644
index 000000000..79ca58d34
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json
@@ -0,0 +1,10 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open",
+ "preparable": true,
+ "delivery_method": {
+ "method_type": "local"
+ }
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json
new file mode 100644
index 000000000..39d73d9a3
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json
@@ -0,0 +1,10 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open",
+ "preparable": true,
+ "delivery_method": {
+ "method_type": "pickup"
+ }
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json
new file mode 100644
index 000000000..a7c45a4e7
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json
@@ -0,0 +1,22 @@
+{
+ "original_fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "closed",
+ "assigned_location_id": "gid://shopify/Location/0"
+ },
+ "moved_fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/2",
+ "status": "open",
+ "assigned_location_id": "gid://shopify/Location/1"
+ },
+ "destination_location_id": "gid://shopify/Location/1",
+ "fulfillment_order_line_items_requested": [
+ {
+ "id": "gid://shopify/FulfillmentOrderLineItem/1",
+ "quantity": 1
+ }
+ ],
+ "source_location": {
+ "id": "gid://shopify/Location/0"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json
new file mode 100644
index 000000000..ca9ffb6dd
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json
@@ -0,0 +1,6 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json
new file mode 100644
index 000000000..7ac3294cc
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json
@@ -0,0 +1,14 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "on_hold",
+ "fulfillment_holds": [
+ {
+ "reason": "other",
+ "reason_notes": "example"
+ }
+ ]
+ },
+ "remaining_fulfillment_order": null,
+ "held_fulfillment_order_line_items": []
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json
new file mode 100644
index 000000000..ade0a48b4
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json
@@ -0,0 +1,7 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "scheduled",
+ "fulfill_at": "2021-12-31T19:00:00-05:00"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json
new file mode 100644
index 000000000..ca9ffb6dd
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json
@@ -0,0 +1,6 @@
+{
+ "fulfillment_order": {
+ "id": "gid://shopify/FulfillmentOrder/1",
+ "status": "open"
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/InventoryItem.json b/integrations/shopify/src/payload-examples/InventoryItem.json
new file mode 100644
index 000000000..9a2c8c3c4
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/InventoryItem.json
@@ -0,0 +1,14 @@
+{
+ "id": 271878346596884000,
+ "sku": "example-sku",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "requires_shipping": true,
+ "cost": null,
+ "country_code_of_origin": null,
+ "province_code_of_origin": null,
+ "harmonized_system_code": null,
+ "tracked": true,
+ "country_harmonized_system_codes": [],
+ "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015"
+}
diff --git a/integrations/shopify/src/payload-examples/InventoryItemDeleted.json b/integrations/shopify/src/payload-examples/InventoryItemDeleted.json
new file mode 100644
index 000000000..62cb6e62f
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/InventoryItemDeleted.json
@@ -0,0 +1,8 @@
+{
+ "id": 271878346596884000,
+ "country_code_of_origin": null,
+ "province_code_of_origin": null,
+ "harmonized_system_code": null,
+ "country_harmonized_system_codes": [],
+ "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015"
+}
diff --git a/integrations/shopify/src/payload-examples/InventoryLevel.json b/integrations/shopify/src/payload-examples/InventoryLevel.json
new file mode 100644
index 000000000..574d37e3b
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/InventoryLevel.json
@@ -0,0 +1,7 @@
+{
+ "inventory_item_id": 271878346596884000,
+ "location_id": 24826418,
+ "available": null,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "admin_graphql_api_id": "gid://shopify/InventoryLevel/24826418?inventory_item_id=271878346596884015"
+}
diff --git a/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json b/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json
new file mode 100644
index 000000000..1648c183c
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json
@@ -0,0 +1,4 @@
+{
+ "inventory_item_id": 271878346596884000,
+ "location_id": 24826418
+}
diff --git a/integrations/shopify/src/payload-examples/Location.json b/integrations/shopify/src/payload-examples/Location.json
new file mode 100644
index 000000000..26ca46f62
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Location.json
@@ -0,0 +1,19 @@
+{
+ "id": 866550311766439000,
+ "name": "Example Shop",
+ "address1": "34 Example Street",
+ "address2": "Next to example",
+ "city": "ottawa",
+ "zip": "k1n5t5",
+ "province": "ontario",
+ "country": "CA",
+ "phone": "555-555-5555",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "country_code": "CA",
+ "country_name": "Canada",
+ "province_code": "ON",
+ "legacy": false,
+ "active": true,
+ "admin_graphql_api_id": "gid://shopify/Location/866550311766439020"
+}
diff --git a/integrations/shopify/src/payload-examples/Market.json b/integrations/shopify/src/payload-examples/Market.json
new file mode 100644
index 000000000..d5aa1a632
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Market.json
@@ -0,0 +1,10 @@
+{
+ "id": 188558248,
+ "name": "United States",
+ "enabled": true,
+ "regions": [
+ {
+ "country_code": "US"
+ }
+ ]
+}
diff --git a/integrations/shopify/src/payload-examples/Order.json b/integrations/shopify/src/payload-examples/Order.json
new file mode 100644
index 000000000..7bd5dba92
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Order.json
@@ -0,0 +1,392 @@
+{
+ "id": 820982911946154500,
+ "admin_graphql_api_id": "gid://shopify/Order/820982911946154508",
+ "app_id": null,
+ "browser_ip": null,
+ "buyer_accepts_marketing": true,
+ "cancel_reason": "customer",
+ "cancelled_at": "2021-12-31T19:00:00-05:00",
+ "cart_token": null,
+ "checkout_id": null,
+ "checkout_token": null,
+ "client_details": null,
+ "closed_at": null,
+ "confirmation_number": null,
+ "confirmed": false,
+ "contact_email": "jon@example.com",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "currency": "USD",
+ "current_subtotal_price": "398.00",
+ "current_subtotal_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_additional_fees_set": null,
+ "current_total_discounts": "0.00",
+ "current_total_discounts_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_duties_set": null,
+ "current_total_price": "398.00",
+ "current_total_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "current_total_tax": "0.00",
+ "current_total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "customer_locale": "en",
+ "device_id": null,
+ "discount_codes": [],
+ "email": "jon@example.com",
+ "estimated_taxes": false,
+ "financial_status": "voided",
+ "fulfillment_status": "pending",
+ "landing_site": null,
+ "landing_site_ref": null,
+ "location_id": null,
+ "merchant_of_record_app_id": null,
+ "name": "#9999",
+ "note": null,
+ "note_attributes": [],
+ "number": 234,
+ "order_number": 1234,
+ "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg",
+ "original_total_additional_fees_set": null,
+ "original_total_duties_set": null,
+ "payment_gateway_names": ["visa", "bogus"],
+ "phone": null,
+ "po_number": null,
+ "presentment_currency": "USD",
+ "processed_at": null,
+ "reference": null,
+ "referring_site": null,
+ "source_identifier": null,
+ "source_name": "web",
+ "source_url": null,
+ "subtotal_price": "388.00",
+ "subtotal_price_set": {
+ "shop_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ }
+ },
+ "tags": "",
+ "tax_exempt": false,
+ "tax_lines": [],
+ "taxes_included": false,
+ "test": true,
+ "token": "123456abcd",
+ "total_discounts": "20.00",
+ "total_discounts_set": {
+ "shop_money": {
+ "amount": "20.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "20.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_line_items_price": "398.00",
+ "total_line_items_price_set": {
+ "shop_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "398.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_outstanding": "398.00",
+ "total_price": "388.00",
+ "total_price_set": {
+ "shop_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "388.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_shipping_price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tax": "0.00",
+ "total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tip_received": "0.00",
+ "total_weight": 0,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "user_id": null,
+ "billing_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "customer": {
+ "id": 115310627314723950,
+ "email": "john@example.com",
+ "accepts_marketing": false,
+ "created_at": null,
+ "updated_at": null,
+ "first_name": "John",
+ "last_name": "Smith",
+ "state": "disabled",
+ "note": null,
+ "verified_email": true,
+ "multipass_identifier": null,
+ "tax_exempt": false,
+ "phone": null,
+ "email_marketing_consent": {
+ "state": "not_subscribed",
+ "opt_in_level": null,
+ "consent_updated_at": null
+ },
+ "sms_marketing_consent": null,
+ "tags": "",
+ "currency": "USD",
+ "accepts_marketing_updated_at": null,
+ "marketing_opt_in_level": null,
+ "tax_exemptions": [],
+ "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954",
+ "default_address": {
+ "id": 715243470612851200,
+ "customer_id": 115310627314723950,
+ "first_name": null,
+ "last_name": null,
+ "company": null,
+ "address1": "123 Elm St.",
+ "address2": null,
+ "city": "Ottawa",
+ "province": "Ontario",
+ "country": "Canada",
+ "zip": "K2H7A8",
+ "phone": "123-123-1234",
+ "name": "",
+ "province_code": "ON",
+ "country_code": "CA",
+ "country_name": "Canada",
+ "default": true
+ }
+ },
+ "discount_applications": [],
+ "fulfillments": [],
+ "line_items": [
+ {
+ "id": 866550311766439000,
+ "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020",
+ "attributed_staffs": [
+ {
+ "id": "gid://shopify/StaffMember/902541635",
+ "quantity": 1
+ }
+ ],
+ "fulfillable_quantity": 1,
+ "fulfillment_service": "manual",
+ "fulfillment_status": null,
+ "gift_card": false,
+ "grams": 567,
+ "name": "IPod Nano - 8GB",
+ "price": "199.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "product_exists": true,
+ "product_id": 632910392,
+ "properties": [],
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "total_discount": "0.00",
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "variant_id": 808950810,
+ "variant_inventory_management": "shopify",
+ "variant_title": null,
+ "vendor": null,
+ "tax_lines": [],
+ "duties": [],
+ "discount_allocations": []
+ },
+ {
+ "id": 141249953214522980,
+ "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974",
+ "attributed_staffs": [],
+ "fulfillable_quantity": 1,
+ "fulfillment_service": "manual",
+ "fulfillment_status": null,
+ "gift_card": false,
+ "grams": 567,
+ "name": "IPod Nano - 8GB",
+ "price": "199.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "product_exists": true,
+ "product_id": 632910392,
+ "properties": [],
+ "quantity": 1,
+ "requires_shipping": true,
+ "sku": "IPOD2008PINK",
+ "taxable": true,
+ "title": "IPod Nano - 8GB",
+ "total_discount": "0.00",
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "variant_id": 808950810,
+ "variant_inventory_management": "shopify",
+ "variant_title": null,
+ "vendor": null,
+ "tax_lines": [],
+ "duties": [],
+ "discount_allocations": []
+ }
+ ],
+ "payment_terms": null,
+ "refunds": [],
+ "shipping_address": {
+ "first_name": "Steve",
+ "address1": "123 Shipping Street",
+ "phone": "555-555-SHIP",
+ "city": "Shippington",
+ "zip": "40003",
+ "province": "Kentucky",
+ "country": "United States",
+ "last_name": "Shipper",
+ "address2": null,
+ "company": "Shipping Company",
+ "latitude": null,
+ "longitude": null,
+ "name": "Steve Shipper",
+ "country_code": "US",
+ "province_code": "KY"
+ },
+ "shipping_lines": [
+ {
+ "id": 271878346596884000,
+ "carrier_identifier": null,
+ "code": null,
+ "discounted_price": "10.00",
+ "discounted_price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "phone": null,
+ "price": "10.00",
+ "price_set": {
+ "shop_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "10.00",
+ "currency_code": "USD"
+ }
+ },
+ "requested_fulfillment_service_id": null,
+ "source": "shopify",
+ "title": "Generic Shipping",
+ "tax_lines": [],
+ "discount_allocations": []
+ }
+ ]
+}
diff --git a/integrations/shopify/src/payload-examples/OrderEdited.json b/integrations/shopify/src/payload-examples/OrderEdited.json
new file mode 100644
index 000000000..d1e71f923
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/OrderEdited.json
@@ -0,0 +1,34 @@
+{
+ "order_edit": {
+ "id": 78912328793123780,
+ "app_id": null,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "notify_customer": false,
+ "order_id": 820982911946154500,
+ "staff_note": "",
+ "user_id": null,
+ "line_items": {
+ "additions": [
+ {
+ "id": 78643924236718240,
+ "delta": 1
+ }
+ ],
+ "removals": [
+ {
+ "id": 866550311766439000,
+ "delta": 1
+ }
+ ]
+ },
+ "discounts": {
+ "line_item": {
+ "additions": [],
+ "removals": []
+ }
+ },
+ "shipping_lines": {
+ "additions": []
+ }
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json b/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json
new file mode 100644
index 000000000..0f62f2e87
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json
@@ -0,0 +1,45 @@
+{
+ "id": 120560818172775260,
+ "order_id": 820982911946154500,
+ "kind": "authorization",
+ "gateway": "visa",
+ "status": "success",
+ "message": null,
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "test": false,
+ "authorization": "1001",
+ "location_id": null,
+ "user_id": null,
+ "parent_id": null,
+ "processed_at": null,
+ "device_id": null,
+ "error_code": null,
+ "source_name": "web",
+ "payment_details": {
+ "credit_card_bin": null,
+ "avs_result_code": null,
+ "cvv_result_code": null,
+ "credit_card_number": "•••• •••• •••• 1234",
+ "credit_card_company": "Visa",
+ "buyer_action_info": null,
+ "credit_card_name": null,
+ "credit_card_wallet": null,
+ "credit_card_expiration_month": null,
+ "credit_card_expiration_year": null
+ },
+ "receipt": {},
+ "amount": "403.00",
+ "currency": "USD",
+ "payment_id": "#9999.1",
+ "total_unsettled_set": {
+ "presentment_money": {
+ "amount": "403.0",
+ "currency": "USD"
+ },
+ "shop_money": {
+ "amount": "403.0",
+ "currency": "USD"
+ }
+ },
+ "admin_graphql_api_id": "gid://shopify/OrderTransaction/120560818172775265"
+}
diff --git a/integrations/shopify/src/payload-examples/PaymentSchedule.json b/integrations/shopify/src/payload-examples/PaymentSchedule.json
new file mode 100644
index 000000000..02242e89c
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/PaymentSchedule.json
@@ -0,0 +1,12 @@
+{
+ "id": 606405506930370000,
+ "payment_terms_id": 706405506930370000,
+ "amount": "10.00",
+ "currency": "USD",
+ "issued_at": "2021-01-01T00:00:00-05:00",
+ "due_at": "2021-01-02T00:00:00-05:00",
+ "completed_at": "2021-01-02T00:00:00-05:00",
+ "created_at": "2021-01-01T00:00:00-05:00",
+ "updated_at": "2021-01-01T00:00:01-05:00",
+ "admin_graphql_api_id": "gid://shopify/PaymentSchedule/606405506930370084"
+}
diff --git a/integrations/shopify/src/payload-examples/Product.json b/integrations/shopify/src/payload-examples/Product.json
new file mode 100644
index 000000000..5ac128848
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Product.json
@@ -0,0 +1,77 @@
+{
+ "admin_graphql_api_id": "gid://shopify/Product/788032119674292922",
+ "body_html": "An example T-Shirt",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "handle": "example-t-shirt",
+ "id": 788032119674292900,
+ "product_type": "Shirts",
+ "published_at": "2021-12-31T19:00:00-05:00",
+ "template_suffix": null,
+ "title": "Example T-Shirt",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "vendor": "Acme",
+ "status": "active",
+ "published_scope": "web",
+ "tags": "example, mens, t-shirt",
+ "variants": [
+ {
+ "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922",
+ "barcode": null,
+ "compare_at_price": "24.99",
+ "created_at": null,
+ "fulfillment_service": "manual",
+ "id": 642667041472714000,
+ "inventory_management": "shopify",
+ "inventory_policy": "deny",
+ "position": 0,
+ "price": "19.99",
+ "product_id": 788032119674292900,
+ "sku": "example-shirt-s",
+ "taxable": true,
+ "title": "",
+ "updated_at": null,
+ "option1": "Small",
+ "option2": null,
+ "option3": null,
+ "grams": 200,
+ "image_id": null,
+ "weight": 200,
+ "weight_unit": "g",
+ "inventory_item_id": null,
+ "inventory_quantity": 75,
+ "old_inventory_quantity": 75,
+ "requires_shipping": true
+ },
+ {
+ "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962",
+ "barcode": null,
+ "compare_at_price": "24.99",
+ "created_at": null,
+ "fulfillment_service": "manual",
+ "id": 757650484644203900,
+ "inventory_management": "shopify",
+ "inventory_policy": "deny",
+ "position": 0,
+ "price": "19.99",
+ "product_id": 788032119674292900,
+ "sku": "example-shirt-m",
+ "taxable": true,
+ "title": "",
+ "updated_at": null,
+ "option1": "Medium",
+ "option2": null,
+ "option3": null,
+ "grams": 200,
+ "image_id": null,
+ "weight": 200,
+ "weight_unit": "g",
+ "inventory_item_id": null,
+ "inventory_quantity": 50,
+ "old_inventory_quantity": 50,
+ "requires_shipping": true
+ }
+ ],
+ "options": [],
+ "images": [],
+ "image": null
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedCreated.json b/integrations/shopify/src/payload-examples/ProductFeedCreated.json
new file mode 100644
index 000000000..a1c844643
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedCreated.json
@@ -0,0 +1,6 @@
+{
+ "id": "gid://shopify/ProductFeed/",
+ "country": "CA",
+ "language": "EN",
+ "status": ""
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedFullSync.json b/integrations/shopify/src/payload-examples/ProductFeedFullSync.json
new file mode 100644
index 000000000..cf3f3afd8
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedFullSync.json
@@ -0,0 +1,22 @@
+{
+ "metadata": {
+ "action": "CREATE",
+ "type": "FULL",
+ "resource": "PRODUCT",
+ "fullSyncId": "gid://shopify/ProductFullSync/11235",
+ "truncatedFields": [],
+ "occurred_at": "2022-01-01T00:00:00.000Z"
+ },
+ "productFeed": {
+ "id": "gid://shopify/ProductFeed/12345",
+ "shop_id": "gid://shopify/Shop/12345",
+ "language": "EN",
+ "country": "CA"
+ },
+ "fullSync": {
+ "createdAt": "2021-12-31 19:00:00 -0500",
+ "errorCode": "",
+ "status": "completed",
+ "count": 10
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json b/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json
new file mode 100644
index 000000000..dd0fb9e89
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json
@@ -0,0 +1,83 @@
+{
+ "metadata": {
+ "action": "CREATE",
+ "type": "INCREMENTAL",
+ "resource": "PRODUCT",
+ "truncatedFields": [],
+ "occured_at": "2021-12-31T19:00:00-05:00"
+ },
+ "productFeed": {
+ "id": "gid://shopify/ProductFeed/12345",
+ "shop_id": "gid://shopify/Shop/12345",
+ "language": "EN",
+ "country": "CA"
+ },
+ "product": {
+ "id": "gid://shopify/Product/12345",
+ "title": "Coffee",
+ "description": "The best coffee in the world",
+ "onlineStoreUrl": "https://example.com/products/coffee",
+ "updatedAt": "2021-12-31T19:00:00-05:00",
+ "productType": "Coffee",
+ "vendor": "Cawfee Inc",
+ "handle": "",
+ "isPublished": true,
+ "publishedAt": "2021-12-31T19:00:00-05:00",
+ "images": {
+ "edges": [
+ {
+ "node": {
+ "id": "gid://shopify/ProductImage/394",
+ "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331",
+ "height": 3024,
+ "width": 4032
+ }
+ }
+ ]
+ },
+ "options": [
+ {
+ "name": "Title",
+ "values": ["151cm", "155cm", "158cm"]
+ }
+ ],
+ "variants": {
+ "edges": [
+ {
+ "node": {
+ "id": "gid://shopify/ProductVariant/1",
+ "title": "151cm",
+ "price": {
+ "amount": "100.00",
+ "currencyCode": "CAD"
+ },
+ "compareAtPrice": null,
+ "barcode": null,
+ "weight": 2.3,
+ "weightUnit": "KILOGRAMS",
+ "requireShipping": true,
+ "image": null,
+ "selectedOptions": [
+ {
+ "name": "Title",
+ "value": "151cm"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "seo": {
+ "title": "seo title",
+ "description": "seo description"
+ },
+ "metafields": [
+ {
+ "namespace": "inventory",
+ "key": "is_counting_pieces",
+ "value": "true",
+ "type": "boolean"
+ }
+ ]
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedsCreate.json b/integrations/shopify/src/payload-examples/ProductFeedsCreate.json
new file mode 100644
index 000000000..a1c844643
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedsCreate.json
@@ -0,0 +1,6 @@
+{
+ "id": "gid://shopify/ProductFeed/",
+ "country": "CA",
+ "language": "EN",
+ "status": ""
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json b/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json
new file mode 100644
index 000000000..cf3f3afd8
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json
@@ -0,0 +1,22 @@
+{
+ "metadata": {
+ "action": "CREATE",
+ "type": "FULL",
+ "resource": "PRODUCT",
+ "fullSyncId": "gid://shopify/ProductFullSync/11235",
+ "truncatedFields": [],
+ "occurred_at": "2022-01-01T00:00:00.000Z"
+ },
+ "productFeed": {
+ "id": "gid://shopify/ProductFeed/12345",
+ "shop_id": "gid://shopify/Shop/12345",
+ "language": "EN",
+ "country": "CA"
+ },
+ "fullSync": {
+ "createdAt": "2021-12-31 19:00:00 -0500",
+ "errorCode": "",
+ "status": "completed",
+ "count": 10
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json b/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json
new file mode 100644
index 000000000..dd0fb9e89
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json
@@ -0,0 +1,83 @@
+{
+ "metadata": {
+ "action": "CREATE",
+ "type": "INCREMENTAL",
+ "resource": "PRODUCT",
+ "truncatedFields": [],
+ "occured_at": "2021-12-31T19:00:00-05:00"
+ },
+ "productFeed": {
+ "id": "gid://shopify/ProductFeed/12345",
+ "shop_id": "gid://shopify/Shop/12345",
+ "language": "EN",
+ "country": "CA"
+ },
+ "product": {
+ "id": "gid://shopify/Product/12345",
+ "title": "Coffee",
+ "description": "The best coffee in the world",
+ "onlineStoreUrl": "https://example.com/products/coffee",
+ "updatedAt": "2021-12-31T19:00:00-05:00",
+ "productType": "Coffee",
+ "vendor": "Cawfee Inc",
+ "handle": "",
+ "isPublished": true,
+ "publishedAt": "2021-12-31T19:00:00-05:00",
+ "images": {
+ "edges": [
+ {
+ "node": {
+ "id": "gid://shopify/ProductImage/394",
+ "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331",
+ "height": 3024,
+ "width": 4032
+ }
+ }
+ ]
+ },
+ "options": [
+ {
+ "name": "Title",
+ "values": ["151cm", "155cm", "158cm"]
+ }
+ ],
+ "variants": {
+ "edges": [
+ {
+ "node": {
+ "id": "gid://shopify/ProductVariant/1",
+ "title": "151cm",
+ "price": {
+ "amount": "100.00",
+ "currencyCode": "CAD"
+ },
+ "compareAtPrice": null,
+ "barcode": null,
+ "weight": 2.3,
+ "weightUnit": "KILOGRAMS",
+ "requireShipping": true,
+ "image": null,
+ "selectedOptions": [
+ {
+ "name": "Title",
+ "value": "151cm"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "seo": {
+ "title": "seo title",
+ "description": "seo description"
+ },
+ "metafields": [
+ {
+ "namespace": "inventory",
+ "key": "is_counting_pieces",
+ "value": "true",
+ "type": "boolean"
+ }
+ ]
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ProductListing.json b/integrations/shopify/src/payload-examples/ProductListing.json
new file mode 100644
index 000000000..45c753849
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductListing.json
@@ -0,0 +1,99 @@
+{
+ "product_listing": {
+ "product_id": 788032119674292900,
+ "created_at": null,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "body_html": "An example T-Shirt",
+ "handle": "example-t-shirt",
+ "product_type": "Shirts",
+ "title": "Example T-Shirt",
+ "vendor": "Acme",
+ "available": false,
+ "tags": "example, mens, t-shirt",
+ "published_at": "2021-12-31T19:00:00-05:00",
+ "variants": [
+ {
+ "id": 642667041472714000,
+ "title": "",
+ "option_values": [
+ {
+ "option_id": 527050010214937800,
+ "name": "Title",
+ "value": "Small"
+ }
+ ],
+ "price": "19.99",
+ "formatted_price": "$19.99",
+ "compare_at_price": "24.99",
+ "grams": 200,
+ "requires_shipping": true,
+ "sku": "example-shirt-s",
+ "barcode": null,
+ "taxable": true,
+ "position": 0,
+ "available": false,
+ "inventory_policy": "deny",
+ "inventory_quantity": 0,
+ "inventory_management": "shopify",
+ "fulfillment_service": "manual",
+ "weight": 200,
+ "weight_unit": "g",
+ "image_id": null,
+ "created_at": null,
+ "updated_at": null
+ },
+ {
+ "id": 757650484644203900,
+ "title": "",
+ "option_values": [
+ {
+ "option_id": 527050010214937800,
+ "name": "Title",
+ "value": "Medium"
+ }
+ ],
+ "price": "19.99",
+ "formatted_price": "$19.99",
+ "compare_at_price": "24.99",
+ "grams": 200,
+ "requires_shipping": true,
+ "sku": "example-shirt-m",
+ "barcode": null,
+ "taxable": true,
+ "position": 0,
+ "available": false,
+ "inventory_policy": "deny",
+ "inventory_quantity": 0,
+ "inventory_management": "shopify",
+ "fulfillment_service": "manual",
+ "weight": 200,
+ "weight_unit": "g",
+ "image_id": null,
+ "created_at": null,
+ "updated_at": null
+ }
+ ],
+ "images": [
+ {
+ "id": 539438707724640960,
+ "created_at": null,
+ "position": 0,
+ "updated_at": null,
+ "product_id": 788032119674292900,
+ "src": "//cdn.shopify.com/shopifycloud/shopify/assets/shopify_shirt-39bb555874ecaeed0a1170417d58bbcf792f7ceb56acfe758384f788710ba635.png",
+ "variant_ids": [],
+ "width": 323,
+ "height": 434
+ }
+ ],
+ "options": [
+ {
+ "id": 527050010214937800,
+ "name": "Title",
+ "product_id": 788032119674292900,
+ "position": 1,
+ "values": ["Small", "Medium"]
+ }
+ ]
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ProductListingRemoved.json b/integrations/shopify/src/payload-examples/ProductListingRemoved.json
new file mode 100644
index 000000000..9726e17cb
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ProductListingRemoved.json
@@ -0,0 +1,5 @@
+{
+ "product_listing": {
+ "product_id": 788032119674292900
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/Refund.json b/integrations/shopify/src/payload-examples/Refund.json
new file mode 100644
index 000000000..49e7ebeef
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Refund.json
@@ -0,0 +1,207 @@
+{
+ "id": 890088186047892400,
+ "order_id": 820982911946154500,
+ "created_at": null,
+ "note": "Things were damaged",
+ "user_id": 548380009,
+ "processed_at": null,
+ "restock": false,
+ "duties": [],
+ "total_duties_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "return": null,
+ "admin_graphql_api_id": "gid://shopify/Refund/890088186047892319",
+ "refund_line_items": [
+ {
+ "id": 866550311766439000,
+ "quantity": 1,
+ "line_item_id": 866550311766439000,
+ "location_id": null,
+ "restock_type": "no_restock",
+ "subtotal": 199,
+ "total_tax": 0,
+ "subtotal_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "line_item": {
+ "id": 866550311766439000,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "0.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020",
+ "tax_lines": []
+ }
+ },
+ {
+ "id": 141249953214523040,
+ "quantity": 1,
+ "line_item_id": 141249953214522980,
+ "location_id": null,
+ "restock_type": "no_restock",
+ "subtotal": 199,
+ "total_tax": 0,
+ "subtotal_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_tax_set": {
+ "shop_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "0.00",
+ "currency_code": "USD"
+ }
+ },
+ "line_item": {
+ "id": 141249953214522980,
+ "variant_id": 808950810,
+ "title": "IPod Nano - 8GB",
+ "quantity": 1,
+ "sku": "IPOD2008PINK",
+ "variant_title": null,
+ "vendor": null,
+ "fulfillment_service": "manual",
+ "product_id": 632910392,
+ "requires_shipping": true,
+ "taxable": true,
+ "gift_card": false,
+ "name": "IPod Nano - 8GB",
+ "variant_inventory_management": "shopify",
+ "properties": [],
+ "product_exists": true,
+ "fulfillable_quantity": 1,
+ "grams": 567,
+ "price": "199.00",
+ "total_discount": "5.00",
+ "fulfillment_status": null,
+ "price_set": {
+ "shop_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "199.00",
+ "currency_code": "USD"
+ }
+ },
+ "total_discount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ },
+ "discount_allocations": [
+ {
+ "amount": "5.00",
+ "discount_application_index": 0,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ },
+ {
+ "amount": "5.00",
+ "discount_application_index": 2,
+ "amount_set": {
+ "shop_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ },
+ "presentment_money": {
+ "amount": "5.00",
+ "currency_code": "USD"
+ }
+ }
+ }
+ ],
+ "duties": [],
+ "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974",
+ "tax_lines": []
+ }
+ }
+ ],
+ "transactions": [],
+ "order_adjustments": []
+}
diff --git a/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json b/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json
new file mode 100644
index 000000000..78be9170b
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json
@@ -0,0 +1,99 @@
+{
+ "scheduled_product_listing": {
+ "product_id": 788032119674292900,
+ "created_at": null,
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "body_html": "An example T-Shirt",
+ "handle": "example-t-shirt",
+ "product_type": "Shirts",
+ "title": "Example T-Shirt",
+ "vendor": "Acme",
+ "available": false,
+ "tags": "example, mens, t-shirt",
+ "variants": [
+ {
+ "id": 642667041472714000,
+ "title": "",
+ "option_values": [
+ {
+ "option_id": 527050010214937800,
+ "name": "Title",
+ "value": "Small"
+ }
+ ],
+ "price": "19.99",
+ "formatted_price": "$19.99",
+ "compare_at_price": "24.99",
+ "grams": 200,
+ "requires_shipping": true,
+ "sku": "example-shirt-s",
+ "barcode": null,
+ "taxable": true,
+ "position": 0,
+ "available": false,
+ "inventory_policy": "deny",
+ "inventory_quantity": 0,
+ "inventory_management": "shopify",
+ "fulfillment_service": "manual",
+ "weight": 200,
+ "weight_unit": "g",
+ "image_id": null,
+ "created_at": null,
+ "updated_at": null
+ },
+ {
+ "id": 757650484644203900,
+ "title": "",
+ "option_values": [
+ {
+ "option_id": 527050010214937800,
+ "name": "Title",
+ "value": "Medium"
+ }
+ ],
+ "price": "19.99",
+ "formatted_price": "$19.99",
+ "compare_at_price": "24.99",
+ "grams": 200,
+ "requires_shipping": true,
+ "sku": "example-shirt-m",
+ "barcode": null,
+ "taxable": true,
+ "position": 0,
+ "available": false,
+ "inventory_policy": "deny",
+ "inventory_quantity": 0,
+ "inventory_management": "shopify",
+ "fulfillment_service": "manual",
+ "weight": 200,
+ "weight_unit": "g",
+ "image_id": null,
+ "created_at": null,
+ "updated_at": null
+ }
+ ],
+ "publish_at": null,
+ "images": [
+ {
+ "id": 539438707724640960,
+ "created_at": null,
+ "position": 0,
+ "updated_at": null,
+ "product_id": 788032119674292900,
+ "src": "//cdn.shopify.com/shopifycloud/shopify/assets/shopify_shirt-39bb555874ecaeed0a1170417d58bbcf792f7ceb56acfe758384f788710ba635.png",
+ "variant_ids": [],
+ "width": 323,
+ "height": 434
+ }
+ ],
+ "options": [
+ {
+ "id": 527050010214937800,
+ "name": "Title",
+ "product_id": 788032119674292900,
+ "position": 1,
+ "values": ["Small", "Medium"]
+ }
+ ]
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json b/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json
new file mode 100644
index 000000000..8dcbd3533
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json
@@ -0,0 +1,5 @@
+{
+ "scheduled_product_listing": {
+ "product_id": 788032119674292900
+ }
+}
diff --git a/integrations/shopify/src/payload-examples/SellingPlanGroup.json b/integrations/shopify/src/payload-examples/SellingPlanGroup.json
new file mode 100644
index 000000000..6d81d4cb2
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/SellingPlanGroup.json
@@ -0,0 +1,36 @@
+{
+ "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518989",
+ "id": 1039518989,
+ "name": "Subscribe & Save",
+ "merchant_code": "sub-n-save",
+ "admin_graphql_api_app": "gid://shopify/App/2525000003",
+ "app_id": null,
+ "description": null,
+ "options": ["Delivery every"],
+ "position": null,
+ "summary": "1 delivery frequency, discount",
+ "selling_plans": [
+ {
+ "name": "Pay every month deliver every month",
+ "options": ["month"],
+ "position": null,
+ "description": null,
+ "billing_policy": {
+ "interval": "month",
+ "interval_count": 1,
+ "min_cycles": null,
+ "max_cycles": null
+ },
+ "delivery_policy": {
+ "interval": "month",
+ "interval_count": 1,
+ "anchors": [],
+ "cutoff": null,
+ "pre_anchor_behavior": "asap"
+ },
+ "pricing_policies": []
+ }
+ ],
+ "product_variants": [],
+ "products": []
+}
diff --git a/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json b/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json
new file mode 100644
index 000000000..b4ddaf197
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json
@@ -0,0 +1,4 @@
+{
+ "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518994",
+ "id": 1039518994
+}
diff --git a/integrations/shopify/src/payload-examples/Shop.json b/integrations/shopify/src/payload-examples/Shop.json
new file mode 100644
index 000000000..04a25b206
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Shop.json
@@ -0,0 +1,56 @@
+{
+ "id": 548380009,
+ "name": "Super Toys",
+ "email": "super@supertoys.com",
+ "domain": null,
+ "province": "Tennessee",
+ "country": "US",
+ "address1": "190 MacLaren Street",
+ "zip": "37178",
+ "city": "Houston",
+ "source": null,
+ "phone": "3213213210",
+ "latitude": null,
+ "longitude": null,
+ "primary_locale": "en",
+ "address2": null,
+ "created_at": null,
+ "updated_at": null,
+ "country_code": "US",
+ "country_name": "United States",
+ "currency": "USD",
+ "customer_email": "super@supertoys.com",
+ "timezone": "(GMT-05:00) Eastern Time (US & Canada)",
+ "iana_timezone": null,
+ "shop_owner": "John Smith",
+ "money_format": "${{amount}}",
+ "money_with_currency_format": "${{amount}} USD",
+ "weight_unit": "kg",
+ "province_code": "TN",
+ "taxes_included": null,
+ "auto_configure_tax_inclusivity": null,
+ "tax_shipping": null,
+ "county_taxes": null,
+ "plan_display_name": "Shopify Plus",
+ "plan_name": "enterprise",
+ "has_discounts": false,
+ "has_gift_cards": true,
+ "myshopify_domain": null,
+ "google_apps_domain": null,
+ "google_apps_login_enabled": null,
+ "money_in_emails_format": "${{amount}}",
+ "money_with_currency_in_emails_format": "${{amount}} USD",
+ "eligible_for_payments": true,
+ "requires_extra_payments_agreement": false,
+ "password_enabled": null,
+ "has_storefront": true,
+ "finances": true,
+ "primary_location_id": 655441491,
+ "checkout_api_supported": true,
+ "multi_location_enabled": true,
+ "setup_required": false,
+ "pre_launch_enabled": false,
+ "enabled_presentment_currencies": ["USD"],
+ "transactional_sms_disabled": false,
+ "marketing_sms_consent_enabled_at_checkout": false
+}
diff --git a/integrations/shopify/src/payload-examples/ShopLocale.json b/integrations/shopify/src/payload-examples/ShopLocale.json
new file mode 100644
index 000000000..6d139d27c
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/ShopLocale.json
@@ -0,0 +1,4 @@
+{
+ "locale": "fr-CA",
+ "published": true
+}
diff --git a/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json b/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json
new file mode 100644
index 000000000..30747f16c
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json
@@ -0,0 +1,12 @@
+{
+ "id": null,
+ "admin_graphql_api_id": null,
+ "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9",
+ "order_id": 1,
+ "admin_graphql_api_order_id": "gid://shopify/Order/1",
+ "subscription_contract_id": 9251185925,
+ "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9251185925",
+ "ready": true,
+ "error_message": null,
+ "error_code": null
+}
diff --git a/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json b/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json
new file mode 100644
index 000000000..30f5825c3
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json
@@ -0,0 +1,10 @@
+{
+ "subscription_contract_id": 8641781273,
+ "cycle_start_at": "2022-10-01T00:00:00-04:00",
+ "cycle_end_at": "2022-11-01T00:00:00-04:00",
+ "cycle_index": 1,
+ "contract_edit": null,
+ "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00",
+ "skipped": false,
+ "edited": false
+}
diff --git a/integrations/shopify/src/payload-examples/SubscriptionContract.json b/integrations/shopify/src/payload-examples/SubscriptionContract.json
new file mode 100644
index 000000000..994cc2937
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/SubscriptionContract.json
@@ -0,0 +1,21 @@
+{
+ "admin_graphql_api_id": "gid://shopify/SubscriptionContract/6097830498",
+ "id": 6097830498,
+ "billing_policy": {
+ "interval": "week",
+ "interval_count": 4,
+ "min_cycles": 1,
+ "max_cycles": 2
+ },
+ "currency_code": "USD",
+ "customer_id": 1,
+ "admin_graphql_api_customer_id": "gid://shopify/Customer/1",
+ "delivery_policy": {
+ "interval": "week",
+ "interval_count": 2
+ },
+ "status": "active",
+ "admin_graphql_api_origin_order_id": "gid://shopify/Order/1",
+ "origin_order_id": 1,
+ "revision_id": "6399800706"
+}
diff --git a/integrations/shopify/src/payload-examples/TenderTransaction.json b/integrations/shopify/src/payload-examples/TenderTransaction.json
new file mode 100644
index 000000000..50cdea8a1
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/TenderTransaction.json
@@ -0,0 +1,12 @@
+{
+ "id": 220982911946154500,
+ "order_id": 820982911946154500,
+ "amount": "403.00",
+ "currency": "USD",
+ "user_id": null,
+ "test": false,
+ "processed_at": null,
+ "remote_reference": "1001",
+ "payment_details": null,
+ "payment_method": "unknown"
+}
diff --git a/integrations/shopify/src/payload-examples/Theme.json b/integrations/shopify/src/payload-examples/Theme.json
new file mode 100644
index 000000000..69a55ece3
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/Theme.json
@@ -0,0 +1,11 @@
+{
+ "id": 512162865275216960,
+ "name": "Comfort",
+ "created_at": "2021-12-31T19:00:00-05:00",
+ "updated_at": "2021-12-31T19:00:00-05:00",
+ "role": "main",
+ "theme_store_id": 1234,
+ "previewable": true,
+ "processing": false,
+ "admin_graphql_api_id": "gid://shopify/Theme/512162865275216980"
+}
diff --git a/integrations/shopify/src/payload-examples/index.ts b/integrations/shopify/src/payload-examples/index.ts
new file mode 100644
index 000000000..707a98a09
--- /dev/null
+++ b/integrations/shopify/src/payload-examples/index.ts
@@ -0,0 +1,224 @@
+import { TypedEventSpecificationExample } from "@trigger.dev/sdk";
+import { slugifyId } from "@trigger.dev/sdk/utils";
+
+import AppUninstalled from "./AppUninstalled.json";
+import AppSubscriptionsUpdate from "./AppSubscriptionsUpdate.json";
+import BulkOperationsFinish from "./BulkOperationsFinish.json";
+import CartsCreate from "./CartsCreate.json";
+import CheckoutsCreate from "./CheckoutsCreate.json";
+import CheckoutsDelete from "./CheckoutsDelete.json";
+import CollectionListingsAdd from "./CollectionListingsAdd.json";
+import CollectionListingsRemove from "./CollectionListingsRemove.json";
+import CollectionsCreate from "./CollectionsCreate.json";
+import CollectionsDelete from "./CollectionsDelete.json";
+import CompaniesCreate from "./CompaniesCreate.json";
+import CompanyContactsCreate from "./CompanyContactsCreate.json";
+import CompanyLocationsCreate from "./CompanyLocationsCreate.json";
+import CustomerGroupsCreate from "./CustomerGroupsCreate.json";
+import CustomerPaymentMethodsCreate from "./CustomerPaymentMethodsCreate.json";
+import CustomersCreate from "./CustomersCreate.json";
+import CustomersDelete from "./CustomersDelete.json";
+import CustomersMerge from "./CustomersMerge.json";
+import CustomersEmailMarketingConsentUpdate from "./CustomersEmailMarketingConsentUpdate.json";
+import CustomersMarketingConsentUpdate from "./CustomersMarketingConsentUpdate.json";
+import DisputesCreate from "./DisputesCreate.json";
+import DomainsCreate from "./DomainsCreate.json";
+import DraftOrdersCreate from "./DraftOrdersCreate.json";
+import DraftOrdersDelete from "./DraftOrdersDelete.json";
+import FulfillmentEventsCreate from "./FulfillmentEventsCreate.json";
+import FulfillmentOrdersCancellationRequestAccepted from "./FulfillmentOrdersCancellationRequestAccepted.json";
+import FulfillmentOrdersCancellationRequestRejected from "./FulfillmentOrdersCancellationRequestRejected.json";
+import FulfillmentOrdersCancellationRequestSubmitted from "./FulfillmentOrdersCancellationRequestSubmitted.json";
+import FulfillmentOrdersCancelled from "./FulfillmentOrdersCancelled.json";
+import FulfillmentOrdersFulfillmentRequestAccepted from "./FulfillmentOrdersFulfillmentRequestAccepted.json";
+import FulfillmentOrdersFulfillmentRequestRejected from "./FulfillmentOrdersFulfillmentRequestRejected.json";
+import FulfillmentOrdersFulfillmentRequestSubmitted from "./FulfillmentOrdersFulfillmentRequestSubmitted.json";
+import FulfillmentOrdersFulfillmentServiceFailedToComplete from "./FulfillmentOrdersFulfillmentServiceFailedToComplete.json";
+import FulfillmentOrdersHoldReleased from "./FulfillmentOrdersHoldReleased.json";
+import FulfillmentOrdersLineItemsPreparedForLocalDelivery from "./FulfillmentOrdersLineItemsPreparedForLocalDelivery.json";
+import FulfillmentOrdersLineItemsPreparedForPickup from "./FulfillmentOrdersLineItemsPreparedForPickup.json";
+import FulfillmentOrdersMoved from "./FulfillmentOrdersMoved.json";
+import FulfillmentOrdersOrderRoutingComplete from "./FulfillmentOrdersOrderRoutingComplete.json";
+import FulfillmentOrdersPlacedOnHold from "./FulfillmentOrdersPlacedOnHold.json";
+import FulfillmentOrdersRescheduled from "./FulfillmentOrdersRescheduled.json";
+import FulfillmentOrdersScheduledFulfillmentOrderReady from "./FulfillmentOrdersScheduledFulfillmentOrderReady.json";
+import OrderTransactionsCreate from "./OrderTransactionsCreate.json";
+import ProductFeedsCreate from "./ProductFeedsCreate.json";
+import ProductFeedsFullSync from "./ProductFeedsFullSync.json";
+import ProductFeedsIncrementalSync from "./ProductFeedsIncrementalSync.json";
+import ScheduledProductListingsAdd from "./ScheduledProductListingsAdd.json";
+import ScheduledProductListingsRemove from "./ScheduledProductListingsRemove.json";
+import Deleted from "./Deleted.json";
+import DeliveryProfile from "./DeliveryProfile.json";
+import Fulfillment from "./Fulfillment.json";
+import InventoryItem from "./InventoryItem.json";
+import InventoryItemDeleted from "./InventoryItemDeleted.json";
+import InventoryLevel from "./InventoryLevel.json";
+import InventoryLevelDisconnected from "./InventoryLevelDisconnected.json";
+import Location from "./Location.json";
+import Market from "./Market.json";
+import Order from "./Order.json";
+import OrderEdited from "./OrderEdited.json";
+import PaymentSchedule from "./PaymentSchedule.json";
+import Product from "./Product.json";
+import ProductListing from "./ProductListing.json";
+import ProductListingRemoved from "./ProductListingRemoved.json";
+import Refund from "./Refund.json";
+import SellingPlanGroup from "./SellingPlanGroup.json";
+import SellingPlanGroupDeleted from "./SellingPlanGroupDeleted.json";
+import Shop from "./Shop.json";
+import ShopLocale from "./ShopLocale.json";
+import SubscriptionBillingAttempt from "./SubscriptionBillingAttempt.json";
+import SubscriptionBillingCycle from "./SubscriptionBillingCycle.json";
+import SubscriptionContract from "./SubscriptionContract.json";
+import TenderTransaction from "./TenderTransaction.json";
+import Theme from "./Theme.json";
+
+const example = (name: string, payload: TEvent): TypedEventSpecificationExample => {
+ return {
+ id: slugifyId(name),
+ name,
+ payload,
+ };
+};
+
+export const shopifyPayloads = {
+ "app/uninstalled": AppUninstalled,
+ "app_subscriptions/update": AppSubscriptionsUpdate,
+ "bulk_operations/finish": BulkOperationsFinish,
+ "carts/create": CartsCreate,
+ "carts/update": CartsCreate,
+ "checkouts/create": CheckoutsCreate,
+ "checkouts/delete": CheckoutsDelete,
+ "checkouts/update": CheckoutsCreate,
+ "collection_listings/add": CollectionListingsAdd,
+ "collection_listings/remove": CollectionListingsRemove,
+ "collection_listings/update": CollectionListingsAdd,
+ "collections/create": CollectionsCreate,
+ "collections/delete": CollectionsDelete,
+ "collections/update": CollectionsCreate,
+ "companies/create": CompaniesCreate,
+ "companies/delete": CompaniesCreate,
+ "companies/update": CompaniesCreate,
+ "company_contact_roles/assign": {},
+ "company_contact_roles/revoke": {},
+ "company_contacts/create": CompanyContactsCreate,
+ "company_contacts/delete": CompanyContactsCreate,
+ "company_contacts/update": CompanyContactsCreate,
+ "company_locations/create": CompanyLocationsCreate,
+ "company_locations/delete": CompanyLocationsCreate,
+ "company_locations/update": CompanyLocationsCreate,
+ "customer_groups/create": CustomerGroupsCreate,
+ "customer_groups/delete": Deleted,
+ "customer_groups/update": CustomerGroupsCreate,
+ "customer_payment_methods/create": CustomerPaymentMethodsCreate,
+ "customer_payment_methods/revoke": CustomerPaymentMethodsCreate,
+ "customer_payment_methods/update": CustomerPaymentMethodsCreate,
+ "customers/create": CustomersCreate,
+ "customers/delete": CustomersDelete,
+ "customers/disable": CustomersCreate,
+ "customers/enable": CustomersCreate,
+ "customers/merge": CustomersMerge,
+ "customers/update": CustomersCreate,
+ "customers_email_marketing_consent/update": CustomersEmailMarketingConsentUpdate,
+ "customers_marketing_consent/update": CustomersMarketingConsentUpdate,
+ "disputes/create": DisputesCreate,
+ "disputes/update": DisputesCreate,
+ "domains/create": DomainsCreate,
+ "domains/destroy": DomainsCreate,
+ "domains/update": DomainsCreate,
+ "draft_orders/create": DraftOrdersCreate,
+ "draft_orders/delete": DraftOrdersDelete,
+ "draft_orders/update": DraftOrdersCreate,
+ "fulfillment_events/create": FulfillmentEventsCreate,
+ "fulfillment_events/delete": FulfillmentEventsCreate,
+ "fulfillment_orders/cancellation_request_accepted": FulfillmentOrdersCancellationRequestAccepted,
+ "fulfillment_orders/cancellation_request_rejected": FulfillmentOrdersCancellationRequestRejected,
+ "fulfillment_orders/cancellation_request_submitted":
+ FulfillmentOrdersCancellationRequestSubmitted,
+ "fulfillment_orders/cancelled": FulfillmentOrdersCancelled,
+ "fulfillment_orders/fulfillment_request_accepted": FulfillmentOrdersFulfillmentRequestAccepted,
+ "fulfillment_orders/fulfillment_request_rejected": FulfillmentOrdersFulfillmentRequestRejected,
+ "fulfillment_orders/fulfillment_request_submitted": FulfillmentOrdersFulfillmentRequestSubmitted,
+ "fulfillment_orders/fulfillment_service_failed_to_complete":
+ FulfillmentOrdersFulfillmentServiceFailedToComplete,
+ "fulfillment_orders/hold_released": FulfillmentOrdersHoldReleased,
+ "fulfillment_orders/line_items_prepared_for_local_delivery":
+ FulfillmentOrdersLineItemsPreparedForLocalDelivery,
+ "fulfillment_orders/line_items_prepared_for_pickup": FulfillmentOrdersLineItemsPreparedForPickup,
+ "fulfillment_orders/moved": FulfillmentOrdersMoved,
+ "fulfillment_orders/order_routing_complete": FulfillmentOrdersOrderRoutingComplete,
+ "fulfillment_orders/placed_on_hold": FulfillmentOrdersPlacedOnHold,
+ "fulfillment_orders/rescheduled": FulfillmentOrdersRescheduled,
+ "fulfillment_orders/scheduled_fulfillment_order_ready":
+ FulfillmentOrdersScheduledFulfillmentOrderReady,
+ "fulfillments/create": Fulfillment,
+ "fulfillments/update": Fulfillment,
+ "inventory_items/create": InventoryItem,
+ "inventory_items/delete": InventoryItemDeleted,
+ "inventory_items/update": InventoryItem,
+ "inventory_levels/connect": InventoryLevel,
+ "inventory_levels/disconnect": InventoryLevelDisconnected,
+ "inventory_levels/update": InventoryLevel,
+ "locales/create": ShopLocale,
+ "locales/update": ShopLocale,
+ "locations/activate": Location,
+ "locations/create": Location,
+ "locations/deactivate": Location,
+ "locations/delete": Deleted,
+ "locations/update": Location,
+ "markets/create": Market,
+ "markets/delete": Deleted,
+ "markets/update": Market,
+ "order_transactions/create": OrderTransactionsCreate,
+ "orders/cancelled": Order,
+ "orders/create": Order,
+ "orders/delete": Deleted,
+ "orders/edited": OrderEdited,
+ "orders/fulfilled": Order,
+ "orders/paid": Order,
+ "orders/partially_fulfilled": Order,
+ "orders/updated": Order,
+ "payment_schedules/due": PaymentSchedule,
+ "product_feeds/create": ProductFeedsCreate,
+ "product_feeds/full_sync": ProductFeedsFullSync,
+ "product_feeds/incremental_sync": ProductFeedsIncrementalSync,
+ "product_listings/add": ProductListing,
+ "product_listings/remove": ProductListingRemoved,
+ "product_listings/update": ProductListing,
+ "products/create": Product,
+ "products/delete": Deleted,
+ "products/update": Product,
+ "profiles/create": DeliveryProfile,
+ "profiles/delete": Deleted,
+ "profiles/update": DeliveryProfile,
+ "refunds/create": Refund,
+ "scheduled_product_listings/add": ScheduledProductListingsAdd,
+ "scheduled_product_listings/remove": ScheduledProductListingsRemove,
+ "scheduled_product_listings/update": ScheduledProductListingsAdd,
+ "selling_plan_groups/create": SellingPlanGroup,
+ "selling_plan_groups/delete": SellingPlanGroupDeleted,
+ "selling_plan_groups/update": SellingPlanGroup,
+ "shop/update": Shop,
+ "subscription_billing_attempts/challenged": SubscriptionBillingAttempt,
+ "subscription_billing_attempts/failure": SubscriptionBillingAttempt,
+ "subscription_billing_attempts/success": SubscriptionBillingAttempt,
+ "subscription_billing_cycle_edits/create": SubscriptionBillingCycle,
+ "subscription_billing_cycle_edits/delete": Deleted,
+ "subscription_billing_cycle_edits/update": SubscriptionBillingCycle,
+ "subscription_contracts/create": SubscriptionContract,
+ "subscription_contracts/update": SubscriptionContract,
+ "tender_transactions/create": TenderTransaction,
+ "themes/create": Theme,
+ "themes/delete": Deleted,
+ "themes/publish": Theme,
+ "themes/update": Theme,
+};
+
+export type ShopifyPayloads = typeof shopifyPayloads;
+
+export const shopifyExample = (
+ name: TName
+): TypedEventSpecificationExample => example(name, shopifyPayloads[name]);
+
+export type ShopifyExamples = typeof shopifyExample;
diff --git a/integrations/shopify/src/rest.ts b/integrations/shopify/src/rest.ts
new file mode 100644
index 000000000..e308be559
--- /dev/null
+++ b/integrations/shopify/src/rest.ts
@@ -0,0 +1,311 @@
+import { ShopifyRestResources, ShopifyRunTask } from "./index";
+import { basicProperties, serializeShopifyResource } from "./utils";
+import {
+ RecursiveShopifySerializer,
+ ResourcesWithStandardMethods,
+ ShopifyInputType,
+} from "./types";
+import { PageInfo, Session } from "@shopify/shopify-api";
+import { OmitIndexSignature, Optional, SomeNonNullable } from "@trigger.dev/integration-kit/types";
+import { z } from "zod";
+
+type AllReturnType = Promise<{
+ data: RecursiveShopifySerializer>["data"]>;
+ pageInfo?: PageInfo;
+}>;
+
+type CountReturnType = Promise<{ count: number }>;
+
+type DeleteReturnType = Promise;
+
+type SaveReturnType<
+ TResource extends ShopifyRestResources[ResourcesWithStandardMethods],
+ TUpdate extends boolean,
+ TFromData extends any,
+> = Promise<
+ TUpdate extends true
+ ? SomeNonNullable, "id">
+ : TFromData
+>;
+
+type HasOptionalSession = {
+ session?: Session;
+};
+
+type WithRequiredSession = T & {
+ session: Session;
+};
+
+export class Resource<
+ TResourceType extends ResourcesWithStandardMethods,
+ TResource extends ShopifyRestResources[TResourceType] = ShopifyRestResources[TResourceType],
+> {
+ constructor(
+ private runTask: ShopifyRunTask,
+ private session: Session,
+ private resourceType: TResourceType
+ ) {}
+
+ #withSession(params: TParams): WithRequiredSession {
+ const { session, ...paramsWithoutSession } = params;
+
+ return {
+ session: session ?? this.session,
+ ...paramsWithoutSession,
+ } as WithRequiredSession;
+ }
+
+ /**
+ * Fetch a single resource by its ID.
+ */
+ async find(key: string, params: Optional[0], "session">) {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ const abc = this.#withSession(params ?? {});
+ const resource = await client.rest[this.resourceType].find(this.#withSession(params));
+
+ return serializeShopifyResource(resource);
+ },
+ {
+ name: `Find ${this.resourceType}`,
+ params,
+ properties: basicProperties(params),
+ }
+ );
+ }
+
+ async #allSinglePage(
+ key: string,
+ pageNumber: number,
+ params?: Optional[0], "session">
+ ): AllReturnType {
+ const { session, ...paramsWithoutSession } = params ?? {};
+
+ return this.runTask(
+ `${key}-page-${String(pageNumber)}`,
+ async (client, task, io) => {
+ const allResponse = await client.rest[this.resourceType].all(
+ this.#withSession(params ?? {})
+ );
+
+ task.outputProperties = [
+ {
+ label: `${this.resourceType}s`,
+ text: String(allResponse.data.length),
+ },
+ ];
+
+ return {
+ data: serializeShopifyResource(allResponse.data) as Awaited<
+ AllReturnType
+ >["data"],
+ pageInfo: allResponse.pageInfo,
+ };
+ },
+ {
+ name: `Get All ${this.resourceType}s`,
+ params: paramsWithoutSession,
+ properties: [
+ {
+ label: "Page Number",
+ text: String(pageNumber),
+ },
+ ],
+ }
+ );
+ }
+
+ /**
+ * Fetch all resources of a given type.
+ */
+ async all(
+ key: string,
+ params?: Optional[0]>, "session"> & {
+ autoPaginate?: boolean;
+ limit?: number;
+ }
+ ): AllReturnType {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ let pageNumber = 0;
+
+ const { data, pageInfo: firstPageInfo } = await this.#allSinglePage(
+ key,
+ pageNumber++,
+ params
+ );
+
+ let pageInfo = firstPageInfo;
+
+ if (params?.autoPaginate && pageInfo) {
+ while (pageInfo.nextPage) {
+ const { data: moreData, pageInfo: morePageInfo } = await this.#allSinglePage(
+ key,
+ pageNumber++,
+ {
+ ...params,
+ ...pageInfo.nextPage.query,
+ }
+ );
+
+ data.push(...(moreData as any));
+
+ pageInfo.nextPage = morePageInfo?.nextPage;
+ }
+ }
+
+ task.outputProperties = [
+ {
+ label: `Total ${this.resourceType}s`,
+ text: String(data.length),
+ },
+ ];
+
+ return { data, pageInfo };
+ },
+ {
+ name: `Get All ${this.resourceType}s`,
+ params,
+ properties: [
+ {
+ label: "Auto Paginate",
+ text: String(!!params?.autoPaginate),
+ },
+ ...(params?.limit
+ ? [
+ {
+ label: "Limit",
+ text: String(params.limit),
+ },
+ ]
+ : []),
+ ],
+ }
+ );
+ }
+
+ /**
+ * Fetch the number of resources of a given type.
+ */
+ async count(
+ key: string,
+ params?: Optional[0]>, "session">
+ ): CountReturnType {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ const countResponse = await client.rest[this.resourceType].count(
+ this.#withSession(params ?? {})
+ );
+
+ const CountResponseSchema = z.object({
+ count: z.number(),
+ });
+
+ const parsed = CountResponseSchema.safeParse(countResponse);
+
+ if (!parsed.success) {
+ return JSON.parse(JSON.stringify(countResponse));
+ }
+
+ task.outputProperties = [
+ {
+ label: "Total",
+ text: String(parsed.data.count),
+ },
+ ];
+
+ return parsed.data;
+ },
+ {
+ name: `Count ${this.resourceType}s`,
+ params,
+ }
+ );
+ }
+
+ /**
+ * Create or update a resource of a given type. The resource will be created if no ID is specified.
+ */
+ async save(
+ key: string,
+ params: {
+ update?: TUpdate;
+ fromData: TFromData;
+ session?: Session;
+ }
+ ): SaveReturnType {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ const resource = new client.rest[this.resourceType](this.#withSession(params));
+
+ // mutate resource object with upserted data by default
+ await resource.save({ update: params.update ?? true });
+
+ return JSON.parse(JSON.stringify(resource));
+ },
+ {
+ name: `Upsert ${this.resourceType}`,
+ params,
+ properties: [
+ ...(params.fromData.id ? basicProperties({ id: params.fromData.id }) : []),
+ {
+ label: "Action",
+ text: params.fromData.id ? "Update" : "Create",
+ },
+ ],
+ }
+ );
+ }
+
+ /**
+ * Delete an existing resource.
+ */
+ async delete(
+ key: string,
+ params: Optional[0], "session">
+ ): DeleteReturnType {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ await client.rest[this.resourceType].delete(this.#withSession(params));
+ return;
+ },
+ {
+ name: `Delete ${this.resourceType}`,
+ params,
+ properties: basicProperties(params),
+ }
+ );
+ }
+}
+
+export class Rest {
+ constructor(
+ private runTask: ShopifyRunTask,
+ private session: Session
+ ) {}
+}
+
+interface MergeProxyConstructor {
+ new , TResult extends Record>(
+ target: TTarget,
+ handler: ProxyHandler
+ ): TTarget & TResult;
+}
+
+type ResourceMap = {
+ [KResourceType in ResourcesWithStandardMethods]: Resource;
+};
+
+const RestProxy = Proxy as MergeProxyConstructor;
+
+export const restProxy = (rest: Rest, session: Session, runTask: ShopifyRunTask) =>
+ new RestProxy(rest, {
+ get: (target, resourceType, receiver) => {
+ return new Resource(runTask, session, resourceType as ResourcesWithStandardMethods);
+ },
+ });
diff --git a/integrations/shopify/src/schemas.ts b/integrations/shopify/src/schemas.ts
new file mode 100644
index 000000000..5381ad16f
--- /dev/null
+++ b/integrations/shopify/src/schemas.ts
@@ -0,0 +1,261 @@
+import { z } from "zod";
+
+export const ApiVersionSchema = z.enum([
+ "2022-10",
+ "2023-01",
+ "2023-04",
+ "2023-07",
+ "2023-10",
+ "unstable",
+]);
+
+export const ApiScopeSchema = z.enum([
+ "read_all_orders",
+ "read_assigned_fulfillment_orders",
+ "write_assigned_fulfillment_orders",
+ "read_cart_transforms",
+ "write_cart_transforms",
+ "read_checkouts",
+ "write_checkouts",
+ "read_checkout_branding_settings",
+ "write_checkout_branding_settings",
+ "read_content",
+ "write_content",
+ "read_customer_merge",
+ "write_customer_merge",
+ "read_customers",
+ "write_customers",
+ "read_customer_payment_methods",
+ "read_discounts",
+ "write_discounts",
+ "read_draft_orders",
+ "write_draft_orders",
+ "read_files",
+ "write_files",
+ "read_fulfillments",
+ "write_fulfillments",
+ "read_gift_cards",
+ "write_gift_cards",
+ "read_inventory",
+ "write_inventory",
+ "read_legal_policies",
+ "read_locales",
+ "write_locales",
+ "read_locations",
+ "read_markets",
+ "write_markets",
+ "read_metaobject_definitions",
+ "write_metaobject_definitions",
+ "read_metaobjects",
+ "write_metaobjects",
+ "read_marketing_events",
+ "write_marketing_events",
+ "read_merchant_approval_signals",
+ "read_merchant_managed_fulfillment_orders",
+ "write_merchant_managed_fulfillment_orders",
+ "read_orders",
+ "write_orders",
+ "read_payment_mandate",
+ "write_payment_mandate",
+ "read_payment_terms",
+ "write_payment_terms",
+ "read_price_rules",
+ "write_price_rules",
+ "read_products",
+ "write_products",
+ "read_product_listings",
+ "read_publications",
+ "write_publications",
+ "read_purchase_options",
+ "write_purchase_options",
+ "read_reports",
+ "write_reports",
+ "read_resource_feedbacks",
+ "write_resource_feedbacks",
+ "read_script_tags",
+ "write_script_tags",
+ "read_shipping",
+ "write_shipping",
+ "read_shopify_payments_disputes",
+ "read_shopify_payments_payouts",
+ "read_own_subscription_contracts",
+ "write_own_subscription_contracts",
+ "read_returns",
+ "write_returns",
+ "read_themes",
+ "write_themes",
+ "read_translations",
+ "write_translations",
+ "read_third_party_fulfillment_orders",
+ "write_third_party_fulfillment_orders",
+ "read_users",
+ "read_order_edits",
+ "write_order_edits",
+ "write_payment_gateways",
+ "write_payment_sessions",
+ "write_pixels",
+ "read_customer_events",
+]);
+
+export type ApiScope = z.infer;
+
+export const WebhookTopicSchema = z.enum([
+ "app/uninstalled",
+ "app_subscriptions/update",
+ "bulk_operations/finish",
+ "carts/create",
+ "carts/update",
+ "checkouts/create",
+ "checkouts/delete",
+ "checkouts/update",
+ "collection_listings/add",
+ "collection_listings/remove",
+ "collection_listings/update",
+ "collections/create",
+ "collections/delete",
+ "collections/update",
+ "companies/create",
+ "companies/delete",
+ "companies/update",
+ "company_contact_roles/assign",
+ "company_contact_roles/revoke",
+ "company_contacts/create",
+ "company_contacts/delete",
+ "company_contacts/update",
+ "company_locations/create",
+ "company_locations/delete",
+ "company_locations/update",
+ "customer_groups/create",
+ "customer_groups/delete",
+ "customer_groups/update",
+ "customer_payment_methods/create",
+ "customer_payment_methods/revoke",
+ "customer_payment_methods/update",
+ "customers/create",
+ "customers/delete",
+ "customers/disable",
+ "customers/enable",
+ "customers/merge",
+ "customers/update",
+ "customers_email_marketing_consent/update",
+ "customers_marketing_consent/update",
+ "disputes/create",
+ "disputes/update",
+ "domains/create",
+ "domains/destroy",
+ "domains/update",
+ "draft_orders/create",
+ "draft_orders/delete",
+ "draft_orders/update",
+ "fulfillment_events/create",
+ "fulfillment_events/delete",
+ "fulfillment_orders/cancellation_request_accepted",
+ "fulfillment_orders/cancellation_request_rejected",
+ "fulfillment_orders/cancellation_request_submitted",
+ "fulfillment_orders/cancelled",
+ "fulfillment_orders/fulfillment_request_accepted",
+ "fulfillment_orders/fulfillment_request_rejected",
+ "fulfillment_orders/fulfillment_request_submitted",
+ "fulfillment_orders/fulfillment_service_failed_to_complete",
+ "fulfillment_orders/hold_released",
+ "fulfillment_orders/line_items_prepared_for_local_delivery",
+ "fulfillment_orders/line_items_prepared_for_pickup",
+ "fulfillment_orders/moved",
+ "fulfillment_orders/order_routing_complete",
+ "fulfillment_orders/placed_on_hold",
+ "fulfillment_orders/rescheduled",
+ "fulfillment_orders/scheduled_fulfillment_order_ready",
+ "fulfillments/create",
+ "fulfillments/update",
+ "inventory_items/create",
+ "inventory_items/delete",
+ "inventory_items/update",
+ "inventory_levels/connect",
+ "inventory_levels/disconnect",
+ "inventory_levels/update",
+ "locales/create",
+ "locales/update",
+ "locations/activate",
+ "locations/create",
+ "locations/deactivate",
+ "locations/delete",
+ "locations/update",
+ "markets/create",
+ "markets/delete",
+ "markets/update",
+ "order_transactions/create",
+ "orders/cancelled",
+ "orders/create",
+ "orders/delete",
+ "orders/edited",
+ "orders/fulfilled",
+ "orders/paid",
+ "orders/partially_fulfilled",
+ "orders/updated",
+ "payment_schedules/due",
+ "product_feeds/create",
+ "product_feeds/full_sync",
+ "product_feeds/incremental_sync",
+ "product_listings/add",
+ "product_listings/remove",
+ "product_listings/update",
+ "products/create",
+ "products/delete",
+ "products/update",
+ "profiles/create",
+ "profiles/delete",
+ "profiles/update",
+ "refunds/create",
+ "scheduled_product_listings/add",
+ "scheduled_product_listings/remove",
+ "scheduled_product_listings/update",
+ "selling_plan_groups/create",
+ "selling_plan_groups/delete",
+ "selling_plan_groups/update",
+ "shop/update",
+ "subscription_billing_attempts/challenged",
+ "subscription_billing_attempts/failure",
+ "subscription_billing_attempts/success",
+ "subscription_billing_cycle_edits/create",
+ "subscription_billing_cycle_edits/delete",
+ "subscription_billing_cycle_edits/update",
+ "subscription_contracts/create",
+ "subscription_contracts/update",
+ "tender_transactions/create",
+ "themes/create",
+ "themes/delete",
+ "themes/publish",
+ "themes/update",
+]);
+
+export type WebhookTopic = z.infer;
+
+export const WebhookHeaderSchema = z.object({
+ "x-shopify-topic": WebhookTopicSchema.or(z.string()),
+ "x-shopify-webhook-id": z.string(),
+ "x-shopify-api-version": ApiVersionSchema.or(z.string()),
+ "x-shopify-hmac-sha256": z.string(),
+ "x-shopify-shop-domain": z.string(),
+ "x-shopify-triggered-at": z.coerce.date(),
+});
+
+export const WebhookSubscriptionSchema = z.object({
+ address: z.string(),
+ api_version: ApiVersionSchema,
+ created_at: z.coerce.date(),
+ fields: z.string().array(),
+ format: z.enum(["json", "xml"]),
+ id: z.number(),
+ metafield_namespaces: z.string().array(),
+ private_metafield_namespaces: z.string().array(),
+ topic: WebhookTopicSchema,
+ updated_at: z.coerce.date(),
+});
+
+export type WebhookSubscription = z.infer;
+
+export const WebhookSubscriptionDataSchema = z.object({
+ webhook: WebhookSubscriptionSchema,
+});
+
+export type WebhookSubscriptionData = z.infer;
diff --git a/integrations/shopify/src/triggers.ts b/integrations/shopify/src/triggers.ts
new file mode 100644
index 000000000..af6f11ab5
--- /dev/null
+++ b/integrations/shopify/src/triggers.ts
@@ -0,0 +1,209 @@
+import { shopifyEvent } from "./events";
+import { EventSpecification } from "@trigger.dev/sdk";
+import { GetWebhookParams, WebhookSource, WebhookTrigger } from "@trigger.dev/sdk/triggers/webhook";
+import { createWebhookEventSource } from "./webhooks";
+
+const shopifyEvents = {
+ "app/uninstalled": shopifyEvent("app/uninstalled"),
+ "app_subscriptions/update": shopifyEvent("app_subscriptions/update"),
+ "bulk_operations/finish": shopifyEvent("bulk_operations/finish"),
+ "carts/create": shopifyEvent("carts/create"),
+ "carts/update": shopifyEvent("carts/update"),
+ "checkouts/create": shopifyEvent("checkouts/create"),
+ "checkouts/delete": shopifyEvent("checkouts/delete"),
+ "checkouts/update": shopifyEvent("checkouts/update"),
+ "collection_listings/add": shopifyEvent("collection_listings/add"),
+ "collection_listings/remove": shopifyEvent("collection_listings/remove"),
+ "collection_listings/update": shopifyEvent("collection_listings/update"),
+ "collections/create": shopifyEvent("collections/create"),
+ "collections/delete": shopifyEvent("collections/delete"),
+ "collections/update": shopifyEvent("collections/update"),
+ "companies/create": shopifyEvent("companies/create"),
+ "companies/delete": shopifyEvent("companies/delete"),
+ "companies/update": shopifyEvent("companies/update"),
+ "company_contact_roles/assign": shopifyEvent("company_contact_roles/assign"),
+ "company_contact_roles/revoke": shopifyEvent("company_contact_roles/revoke"),
+ "company_contacts/create": shopifyEvent("company_contacts/create"),
+ "company_contacts/delete": shopifyEvent("company_contacts/delete"),
+ "company_contacts/update": shopifyEvent("company_contacts/update"),
+ "company_locations/create": shopifyEvent("company_locations/create"),
+ "company_locations/delete": shopifyEvent("company_locations/delete"),
+ "company_locations/update": shopifyEvent("company_locations/update"),
+ "customer_groups/create": shopifyEvent("customer_groups/create"),
+ "customer_groups/delete": shopifyEvent("customer_groups/delete"),
+ "customer_groups/update": shopifyEvent("customer_groups/update"),
+ "customer_payment_methods/create": shopifyEvent("customer_payment_methods/create"),
+ "customer_payment_methods/revoke": shopifyEvent("customer_payment_methods/revoke"),
+ "customer_payment_methods/update": shopifyEvent("customer_payment_methods/update"),
+ "customers/create": shopifyEvent("customers/create"),
+ "customers/delete": shopifyEvent("customers/delete"),
+ "customers/disable": shopifyEvent("customers/disable"),
+ "customers/enable": shopifyEvent("customers/enable"),
+ "customers/merge": shopifyEvent("customers/merge"),
+ "customers/update": shopifyEvent("customers/update"),
+ "customers_email_marketing_consent/update": shopifyEvent(
+ "customers_email_marketing_consent/update"
+ ),
+ "customers_marketing_consent/update": shopifyEvent("customers_marketing_consent/update"),
+ "disputes/create": shopifyEvent("disputes/create"),
+ "disputes/update": shopifyEvent("disputes/update"),
+ "domains/create": shopifyEvent("domains/create"),
+ "domains/destroy": shopifyEvent("domains/destroy"),
+ "domains/update": shopifyEvent("domains/update"),
+ "draft_orders/create": shopifyEvent("draft_orders/create"),
+ "draft_orders/delete": shopifyEvent("draft_orders/delete"),
+ "draft_orders/update": shopifyEvent("draft_orders/update"),
+ "fulfillment_events/create": shopifyEvent("fulfillment_events/create"),
+ "fulfillment_events/delete": shopifyEvent("fulfillment_events/delete"),
+ "fulfillment_orders/cancellation_request_accepted": shopifyEvent(
+ "fulfillment_orders/cancellation_request_accepted"
+ ),
+ "fulfillment_orders/cancellation_request_rejected": shopifyEvent(
+ "fulfillment_orders/cancellation_request_rejected"
+ ),
+ "fulfillment_orders/cancellation_request_submitted": shopifyEvent(
+ "fulfillment_orders/cancellation_request_submitted"
+ ),
+ "fulfillment_orders/cancelled": shopifyEvent("fulfillment_orders/cancelled"),
+ "fulfillment_orders/fulfillment_request_accepted": shopifyEvent(
+ "fulfillment_orders/fulfillment_request_accepted"
+ ),
+ "fulfillment_orders/fulfillment_request_rejected": shopifyEvent(
+ "fulfillment_orders/fulfillment_request_rejected"
+ ),
+ "fulfillment_orders/fulfillment_request_submitted": shopifyEvent(
+ "fulfillment_orders/fulfillment_request_submitted"
+ ),
+ "fulfillment_orders/fulfillment_service_failed_to_complete": shopifyEvent(
+ "fulfillment_orders/fulfillment_service_failed_to_complete"
+ ),
+ "fulfillment_orders/hold_released": shopifyEvent("fulfillment_orders/hold_released"),
+ "fulfillment_orders/line_items_prepared_for_local_delivery": shopifyEvent(
+ "fulfillment_orders/line_items_prepared_for_local_delivery"
+ ),
+ "fulfillment_orders/line_items_prepared_for_pickup": shopifyEvent(
+ "fulfillment_orders/line_items_prepared_for_pickup"
+ ),
+ "fulfillment_orders/moved": shopifyEvent("fulfillment_orders/moved"),
+ "fulfillment_orders/order_routing_complete": shopifyEvent(
+ "fulfillment_orders/order_routing_complete"
+ ),
+ "fulfillment_orders/placed_on_hold": shopifyEvent("fulfillment_orders/placed_on_hold"),
+ "fulfillment_orders/rescheduled": shopifyEvent("fulfillment_orders/rescheduled"),
+ "fulfillment_orders/scheduled_fulfillment_order_ready": shopifyEvent(
+ "fulfillment_orders/scheduled_fulfillment_order_ready"
+ ),
+ "fulfillments/create": shopifyEvent("fulfillments/create"),
+ "fulfillments/update": shopifyEvent("fulfillments/update"),
+ "inventory_items/create": shopifyEvent("inventory_items/create"),
+ "inventory_items/delete": shopifyEvent("inventory_items/delete"),
+ "inventory_items/update": shopifyEvent("inventory_items/update"),
+ "inventory_levels/connect": shopifyEvent("inventory_levels/connect"),
+ "inventory_levels/disconnect": shopifyEvent("inventory_levels/disconnect"),
+ "inventory_levels/update": shopifyEvent("inventory_levels/update"),
+ "locales/create": shopifyEvent("locales/create"),
+ "locales/update": shopifyEvent("locales/update"),
+ "locations/activate": shopifyEvent("locations/activate"),
+ "locations/create": shopifyEvent("locations/create"),
+ "locations/deactivate": shopifyEvent("locations/deactivate"),
+ "locations/delete": shopifyEvent("locations/delete"),
+ "locations/update": shopifyEvent("locations/update"),
+ "markets/create": shopifyEvent("markets/create"),
+ "markets/delete": shopifyEvent("markets/delete"),
+ "markets/update": shopifyEvent("markets/update"),
+ "order_transactions/create": shopifyEvent("order_transactions/create"),
+ "orders/cancelled": shopifyEvent("orders/cancelled"),
+ "orders/create": shopifyEvent("orders/create"),
+ "orders/delete": shopifyEvent("orders/delete"),
+ "orders/edited": shopifyEvent("orders/edited"),
+ "orders/fulfilled": shopifyEvent("orders/fulfilled"),
+ "orders/paid": shopifyEvent("orders/paid"),
+ "orders/partially_fulfilled": shopifyEvent("orders/partially_fulfilled"),
+ "orders/updated": shopifyEvent("orders/updated"),
+ "payment_schedules/due": shopifyEvent("payment_schedules/due"),
+ "product_feeds/create": shopifyEvent("product_feeds/create"),
+ "product_feeds/full_sync": shopifyEvent("product_feeds/full_sync"),
+ "product_feeds/incremental_sync": shopifyEvent("product_feeds/incremental_sync"),
+ "product_listings/add": shopifyEvent("product_listings/add"),
+ "product_listings/remove": shopifyEvent("product_listings/remove"),
+ "product_listings/update": shopifyEvent("product_listings/update"),
+ "products/create": shopifyEvent("products/create"),
+ "products/delete": shopifyEvent("products/delete"),
+ "products/update": shopifyEvent("products/update"),
+ "profiles/create": shopifyEvent("profiles/create"),
+ "profiles/delete": shopifyEvent("profiles/delete"),
+ "profiles/update": shopifyEvent("profiles/update"),
+ "refunds/create": shopifyEvent("refunds/create"),
+ "scheduled_product_listings/add": shopifyEvent("scheduled_product_listings/add"),
+ "scheduled_product_listings/remove": shopifyEvent("scheduled_product_listings/remove"),
+ "scheduled_product_listings/update": shopifyEvent("scheduled_product_listings/update"),
+ "selling_plan_groups/create": shopifyEvent("selling_plan_groups/create"),
+ "selling_plan_groups/delete": shopifyEvent("selling_plan_groups/delete"),
+ "selling_plan_groups/update": shopifyEvent("selling_plan_groups/update"),
+ "shop/update": shopifyEvent("shop/update"),
+ "subscription_billing_attempts/challenged": shopifyEvent(
+ "subscription_billing_attempts/challenged"
+ ),
+ "subscription_billing_attempts/failure": shopifyEvent("subscription_billing_attempts/failure"),
+ "subscription_billing_attempts/success": shopifyEvent("subscription_billing_attempts/success"),
+ "subscription_billing_cycle_edits/create": shopifyEvent(
+ "subscription_billing_cycle_edits/create"
+ ),
+ "subscription_billing_cycle_edits/delete": shopifyEvent(
+ "subscription_billing_cycle_edits/delete"
+ ),
+ "subscription_billing_cycle_edits/update": shopifyEvent(
+ "subscription_billing_cycle_edits/update"
+ ),
+ "subscription_contracts/create": shopifyEvent("subscription_contracts/create"),
+ "subscription_contracts/update": shopifyEvent("subscription_contracts/update"),
+ "tender_transactions/create": shopifyEvent("tender_transactions/create"),
+ "themes/create": shopifyEvent("themes/create"),
+ "themes/delete": shopifyEvent("themes/delete"),
+ "themes/publish": shopifyEvent("themes/publish"),
+ "themes/update": shopifyEvent("themes/update"),
+};
+
+type WebhookCatalogOptions<
+ TEvents extends Record>,
+ TSource extends WebhookSource,
+> = {
+ id: string;
+ events: TEvents;
+ source: TSource;
+};
+
+export class WebhookEventCatalog<
+ TEvents extends Record>,
+ TSource extends WebhookSource,
+> {
+ constructor(private options: WebhookCatalogOptions) {}
+
+ get events() {
+ return this.options.events;
+ }
+
+ get source() {
+ return this.options.source;
+ }
+
+ on>(
+ name: TName,
+ params: TParams
+ ): WebhookTrigger {
+ return new WebhookTrigger({
+ event: this.events[name],
+ params,
+ source: this.source,
+ config: {},
+ });
+ }
+}
+
+export function createWebhookEventCatalog(source: ReturnType) {
+ return new WebhookEventCatalog({
+ id: "shopify",
+ events: shopifyEvents,
+ source,
+ });
+}
diff --git a/integrations/shopify/src/types.ts b/integrations/shopify/src/types.ts
new file mode 100644
index 000000000..1fc7f99ab
--- /dev/null
+++ b/integrations/shopify/src/types.ts
@@ -0,0 +1,62 @@
+import {
+ ObjectNonNullable,
+ OmitFunctions,
+ OmitIndexSignature,
+ OmitValues,
+ Prettify,
+} from "@trigger.dev/integration-kit";
+import { ShopifyRestResources } from "./index";
+import { WebhookTopic } from "./schemas";
+
+type OmitNonSerializable = Omit>, "session">;
+
+export type SerializedShopifyResource = Prettify<
+ TNonNullable extends true ? ObjectNonNullable> : OmitNonSerializable
+>;
+
+export type RecursiveShopifySerializer = T extends object
+ ? T extends Array
+ ? Array>
+ : SerializedShopifyResource
+ : T;
+
+export type ShopifyReturnType<
+ TPayload extends Omit,
+ K extends unknown = unknown,
+> = Promise<
+ Awaited>>
+>;
+
+export type AwaitNested = Omit & {
+ [key in K]: Awaited;
+};
+
+export type ShopifyResource = InstanceType<
+ ShopifyRestResources[TResource]
+>;
+
+export type ShopifyWebhookPayload = {
+ [K in keyof OmitIndexSignature]: Prettify<
+ SerializedShopifyResource>
+ >;
+};
+
+export type ShopifyInputType = {
+ [K in keyof OmitIndexSignature]: Prettify<
+ Partial, false>>
+ > & { id?: number };
+};
+
+type ResourceHasStandardMethods = {
+ [K in keyof ShopifyRestResources]: "find" extends keyof ShopifyRestResources[K]
+ ? ShopifyRestResources[K]["find"] extends (...args: any) => any
+ ? Parameters[0] extends { id: string | number }
+ ? "all" | "count" | "delete" extends keyof ShopifyRestResources[K]
+ ? true
+ : false
+ : false
+ : false
+ : false;
+};
+
+export type ResourcesWithStandardMethods = keyof OmitValues;
diff --git a/integrations/shopify/src/utils.ts b/integrations/shopify/src/utils.ts
new file mode 100644
index 000000000..011b40db4
--- /dev/null
+++ b/integrations/shopify/src/utils.ts
@@ -0,0 +1,41 @@
+import { Base } from "@shopify/shopify-api/rest/base";
+import { RecursiveShopifySerializer } from "./types";
+import { WebhookTopic } from "./schemas";
+import { DisplayProperty, EventSpecificationExample } from "@trigger.dev/sdk";
+import { EventSpecification } from "@trigger.dev/sdk";
+import { titleCase } from "@trigger.dev/integration-kit";
+
+export const basicProperties = (payload: Record) => {
+ return payload.id ? [{ label: "ID", text: String(payload.id) }] : [];
+};
+
+export const serializeShopifyResource = (
+ resource: TResource
+): RecursiveShopifySerializer => {
+ return JSON.parse(JSON.stringify(resource));
+};
+
+const topicToTitle = (topic: WebhookTopic) => {
+ const prettyTopic = titleCase(topic.replace("_", " ").replace("/", " "));
+ return `On ${prettyTopic}`;
+};
+
+export const eventSpec = ({
+ topic,
+ examples,
+ runProperties,
+}: {
+ topic: WebhookTopic;
+ examples?: EventSpecificationExample[];
+ runProperties?: (payload: TEvent) => DisplayProperty[];
+}): EventSpecification => {
+ return {
+ name: topic,
+ title: topicToTitle(topic),
+ source: "shopify.com",
+ icon: "shopify",
+ examples,
+ parsePayload: (payload) => payload as TEvent,
+ runProperties,
+ };
+};
diff --git a/integrations/shopify/src/webhooks.ts b/integrations/shopify/src/webhooks.ts
new file mode 100644
index 000000000..32144e72f
--- /dev/null
+++ b/integrations/shopify/src/webhooks.ts
@@ -0,0 +1,177 @@
+import { IntegrationTaskKey, verifyRequestSignature } from "@trigger.dev/sdk";
+import { z } from "zod";
+import { Shopify, ShopifyRunTask } from "./index";
+import {
+ WebhookHeaderSchema,
+ WebhookSubscription,
+ WebhookSubscriptionDataSchema,
+ WebhookTopic,
+ WebhookTopicSchema,
+} from "./schemas";
+import { WebhookSource } from "@trigger.dev/sdk/triggers/webhook";
+import { registerJobNamespace } from "@trigger.dev/integration-kit/webhooks";
+
+export class Webhooks {
+ constructor(private runTask: ShopifyRunTask) {}
+
+ #apiUrl(client: NonNullable) {
+ const { apiVersion, hostName } = client.config;
+ return new URL(`/admin/api/${apiVersion}/`, `https://${hostName}`);
+ }
+
+ // just here as an example if we ever want better platform support
+ #createWithFetch(
+ key: IntegrationTaskKey,
+ params: {
+ topic: WebhookTopic;
+ address: string;
+ fields?: string[];
+ }
+ ): Promise {
+ return this.runTask(
+ key,
+ async (client, task, io) => {
+ const resource = {
+ webhook: {
+ topic: params.topic,
+ address: params.address,
+ fields: params.fields,
+ },
+ };
+
+ const request = new Request(new URL("webhooks.json", this.#apiUrl(client)), {
+ method: "POST",
+ headers: {
+ "X-Shopify-Access-Token": client.config.adminApiAccessToken,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(resource),
+ });
+
+ const response = await fetch(request.clone());
+
+ if (!response.ok) {
+ await handleWebhookError("WEBHOOK_CREATE", request, response);
+ }
+
+ const webhook = await response.json();
+ const parsed = WebhookSubscriptionDataSchema.parse(webhook);
+
+ return parsed.webhook;
+ },
+ {
+ name: "Create Webhook with Fetch",
+ params,
+ properties: [
+ { label: "Webhook URL", text: params.address },
+ { label: "Topic", text: params.topic },
+ ],
+ }
+ );
+ }
+}
+
+export function createWebhookEventSource(integration: Shopify) {
+ return new WebhookSource({
+ id: "shopify",
+ schemas: {
+ params: z.object({
+ topic: WebhookTopicSchema,
+ // disabled for now, doesn't seem useful and complicates things
+ // fields: z.string().array().optional(),
+ }),
+ // config: z.record(z.string().array()),
+ },
+ version: "0.1.0",
+ integration,
+ key: (params) => params.topic,
+ crud: {
+ create: async ({ io, ctx }) => {
+ const webhook = await io.integration.rest.Webhook.save("create-webhook", {
+ fromData: {
+ address: ctx.url,
+ topic: ctx.params.topic,
+ // fields: ctx.params.fields,
+ },
+ });
+
+ const clientSecret = await io.integration.runTask(
+ "get-client-secret",
+ async (client) => client.config.apiSecretKey
+ );
+
+ await io.store.job.set("set-id", "webhook-id", webhook.id);
+ await io.store.job.set("set-secret", "webhook-secret", clientSecret);
+ },
+ delete: async ({ io, ctx }) => {
+ const webhookId = await io.store.job.get("get-webhook-id", "webhook-id");
+
+ await io.integration.rest.Webhook.delete("delete-webhook", {
+ id: webhookId,
+ });
+
+ await io.store.job.delete("delete-webhook-id", "webhook-id");
+ },
+ update: async ({ io, ctx }) => {
+ const webhookId = await io.store.job.get("get-webhook-id", "webhook-id");
+
+ await io.integration.rest.Webhook.save("update-webhook", {
+ fromData: {
+ id: webhookId,
+ address: ctx.url,
+ topic: ctx.params.topic,
+ // fields: ctx.params.fields,
+ },
+ });
+ },
+ },
+ verify: async ({ request, client, ctx }) => {
+ // TODO: should pass namespaced store instead, e.g. client.store.webhookRegistration.get()
+ const clientSecret = await client.store.env.get(
+ `${registerJobNamespace(ctx.key)}:webhook-secret`
+ );
+
+ return await verifyRequestSignature({
+ request,
+ headerName: "x-shopify-hmac-sha256",
+ headerEncoding: "base64",
+ secret: clientSecret,
+ algorithm: "sha256",
+ });
+ },
+ generateEvents: async ({ request, client }) => {
+ const headers = WebhookHeaderSchema.parse(Object.fromEntries(request.headers));
+
+ const topic = headers["x-shopify-topic"];
+ const triggeredAt = headers["x-shopify-triggered-at"];
+ const idempotencyKey = headers["x-shopify-webhook-id"];
+
+ await client.sendEvent({
+ id: idempotencyKey,
+ payload: await request.json(),
+ source: "shopify.com",
+ name: topic,
+ timestamp: triggeredAt,
+ });
+ },
+ });
+}
+
+export class ShopifyApiError extends Error {
+ constructor(
+ message: string,
+ readonly request: Request,
+ readonly response: Response
+ ) {
+ super(message);
+ this.name = "ShopifyApiError";
+ }
+}
+
+async function handleWebhookError(errorType: string, request: Request, response: Response) {
+ const body = await response.clone().text();
+
+ const message = `[${errorType}] ${response.status} - ${response.statusText} - body: "${body}"`;
+
+ throw new ShopifyApiError(message, request, response);
+}
diff --git a/integrations/shopify/tsconfig.json b/integrations/shopify/tsconfig.json
new file mode 100644
index 000000000..26ae70a15
--- /dev/null
+++ b/integrations/shopify/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@trigger.dev/tsconfig/integration.json",
+ "include": ["./src/**/*.ts", "tsup.config.ts"]
+}
diff --git a/integrations/shopify/tsup.config.ts b/integrations/shopify/tsup.config.ts
new file mode 100644
index 000000000..3071b229a
--- /dev/null
+++ b/integrations/shopify/tsup.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig, deepMergeOptions, integrationOptions } from "@trigger.dev/tsup";
+
+const options = deepMergeOptions(integrationOptions, {
+ // extend base config here
+});
+
+export default defineConfig(options);
diff --git a/packages/cli/src/templates/integration/events.js.j2 b/packages/cli/src/templates/integration/events.js.j2
index 77d100907..f9c85a313 100644
--- a/packages/cli/src/templates/integration/events.js.j2
+++ b/packages/cli/src/templates/integration/events.js.j2
@@ -14,8 +14,8 @@ import { onCommentProperties, onIssueProperties, updatedFromProperties } from ".
export const onComment: EventSpecification> = {
name: "Comment",
title: "On Comment",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
examples: [commentCreated, commentRemoved, commentUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload,
runProperties: (payload) => [
@@ -28,8 +28,8 @@ export const onComment: EventSpecification> = {
name: "Comment",
title: "On Comment Created",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["create"],
},
@@ -41,8 +41,8 @@ export const onCommentCreated: EventSpecification> = {
name: "Comment",
title: "On Comment Removed",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["remove"],
},
@@ -54,8 +54,8 @@ export const onCommentRemoved: EventSpecification> = {
name: "Comment",
title: "On Comment Updated",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["update"],
},
@@ -67,8 +67,8 @@ export const onCommentUpdated: EventSpecification> = {
name: "Issue",
title: "On Issue",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
examples: [issueCreated, issueRemoved, issueUpdated],
parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload,
runProperties: (payload) => [
@@ -81,8 +81,8 @@ export const onIssue: EventSpecification> = {
name: "Issue",
title: "On Issue Created",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["create"],
},
@@ -94,8 +94,8 @@ export const onIssueCreated: EventSpecification> = {
name: "Issue",
title: "On Issue Removed",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["remove"],
},
@@ -107,8 +107,8 @@ export const onIssueRemoved: EventSpecification> = {
name: "Issue",
title: "On Issue Updated",
- source: "linear.app",
- icon: "linear",
+ source: "{{ identifier }}.com",
+ icon: "{{ identifier }}",
filter: {
action: ["update"],
},
diff --git a/packages/cli/src/templates/integration/index.js.j2 b/packages/cli/src/templates/integration/index.js.j2
index 289de3ee1..09e376d2c 100644
--- a/packages/cli/src/templates/integration/index.js.j2
+++ b/packages/cli/src/templates/integration/index.js.j2
@@ -19,7 +19,7 @@ import { Models } from "./models";
export type {{ identifier | capitalize }}IntegrationOptions = {
id: string;
- {{ apiKeyPropertyName }}: string;
+ {{ apiKeyPropertyName }}?: string;
};
export type {{ identifier | capitalize }}RunTask = InstanceType["runTask"];
@@ -32,6 +32,7 @@ export class {{ identifier | capitalize }}{{ " " }} implements TriggerIntegratio
constructor(private options: {{ identifier | capitalize }}IntegrationOptions) {
if (Object.keys(options).includes("{{ apiKeyPropertyName }}") && !options.{{ apiKeyPropertyName }}) {
+ {# FIXME: For some reason the spaces after the variables vanish #}
throw `Can't create {{ identifier | capitalize }} integration (${options.id}) as {{ apiKeyPropertyName }} was undefined`;
}
diff --git a/packages/cli/src/templates/integration/models.js.j2 b/packages/cli/src/templates/integration/models.js.j2
index 0d67c7952..29edda503 100644
--- a/packages/cli/src/templates/integration/models.js.j2
+++ b/packages/cli/src/templates/integration/models.js.j2
@@ -1,12 +1,12 @@
import { IntegrationTaskKey } from "@trigger.dev/sdk";
import { Model, ModelVersion } from "{{ sdkPackage }}";
-import { {{ capitalizedIdentifier }}RunTask } from "./index";
+import { {{ identifier | capitalize }}RunTask } from "./index";
import { modelProperties } from "./utils";
-import { {{ capitalizedIdentifier }}ReturnType } from "./types";
+import { {{ identifier | capitalize }}ReturnType } from "./types";
export class Models {
- constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {}
+ constructor(private runTask: {{ identifier | capitalize }}RunTask) {}
get(
key: IntegrationTaskKey,
@@ -14,7 +14,7 @@ export class Models {
model_owner: string;
model_name: string;
}
- ): {{ capitalizedIdentifier }}ReturnType {
+ ): {{ identifier | capitalize }}ReturnType {
return this.runTask(
key,
(client) => {
@@ -34,7 +34,7 @@ export class Models {
}
class Versions {
- constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {}
+ constructor(private runTask: {{ identifier | capitalize }}RunTask) {}
get(
key: IntegrationTaskKey,
@@ -43,7 +43,7 @@ class Versions {
model_name: string;
version_id: string;
}
- ): {{ capitalizedIdentifier }}ReturnType {
+ ): {{ identifier | capitalize }}ReturnType {
return this.runTask(
key,
(client) => {
@@ -63,7 +63,7 @@ class Versions {
model_owner: string;
model_name: string;
}
- ): {{ capitalizedIdentifier }}ReturnType {
+ ): {{ identifier | capitalize }}ReturnType {
return this.runTask(
key,
(client) => {
diff --git a/packages/cli/src/templates/integration/package.json.j2 b/packages/cli/src/templates/integration/package.json.j2
index 6e1391c90..308b6b436 100644
--- a/packages/cli/src/templates/integration/package.json.j2
+++ b/packages/cli/src/templates/integration/package.json.j2
@@ -32,7 +32,7 @@
"{{ latestVersion.name }}": "^{{ latestVersion.version }}",
"{{ sdkVersion.name }}": "{{ sdkVersion.version }}",
"{{ integrationKitVersion.name }}": "{{ integrationKitVersion.version }}",
- "zod": "3.21.4"
+ "zod": "3.22.3"
},
"engines": {
"node": ">=16.8.0"
diff --git a/packages/cli/src/templates/integration/webhooks.js.j2 b/packages/cli/src/templates/integration/webhooks.js.j2
index 9bb55e514..10d0dd18a 100644
--- a/packages/cli/src/templates/integration/webhooks.js.j2
+++ b/packages/cli/src/templates/integration/webhooks.js.j2
@@ -131,7 +131,6 @@ type {{ identifier | capitalize }}Events = (typeof events)[keyof typeof events];
export type TriggerParams = {
teamId?: string;
- filter?: EventFilter;
};
type CreateTriggersResult = ExternalSourceTrigger<
diff --git a/packages/core/src/schemas/api.ts b/packages/core/src/schemas/api.ts
index 582e2aceb..e207c420c 100644
--- a/packages/core/src/schemas/api.ts
+++ b/packages/core/src/schemas/api.ts
@@ -37,6 +37,18 @@ export const UpdateTriggerSourceBodyV2Schema = z.object({
});
export type UpdateTriggerSourceBodyV2 = z.infer;
+export const UpdateWebhookBodySchema = z.discriminatedUnion("active", [
+ z.object({
+ active: z.literal(false),
+ }),
+ z.object({
+ active: z.literal(true),
+ config: z.record(z.string().array()),
+ }),
+]);
+
+export type UpdateWebhookBody = z.infer;
+
export const RegisterHTTPTriggerSourceBodySchema = z.object({
type: z.literal("HTTP"),
url: z.string().url(),
@@ -56,6 +68,36 @@ export const RegisterSourceChannelBodySchema = z.discriminatedUnion("type", [
RegisterSQSTriggerSourceBodySchema,
]);
+export const REGISTER_WEBHOOK = "dev.trigger.webhook.register";
+export const DELIVER_WEBHOOK_REQUEST = "dev.trigger.webhook.deliver";
+
+export const RegisterWebhookSourceSchema = z.object({
+ key: z.string(),
+ params: z.any(),
+ config: z.any(),
+ active: z.boolean(),
+ secret: z.string(),
+ url: z.string(),
+ data: DeserializedJsonSchema.optional(),
+ clientId: z.string().optional(),
+});
+
+export type RegisterWebhookSource = z.infer;
+
+export const RegisterWebhookPayloadSchema = z.object({
+ active: z.boolean(),
+ params: z.any().optional(),
+ config: z.object({
+ current: z.record(z.string().array()),
+ desired: z.record(z.string().array()),
+ }),
+ // from HTTP Endpoint
+ url: z.string(),
+ secret: z.string(),
+});
+
+export type RegisterWebhookPayload = z.infer;
+
export const REGISTER_SOURCE_EVENT_V1 = "dev.trigger.source.register";
export const REGISTER_SOURCE_EVENT_V2 = "dev.trigger.source.register.v2";
@@ -173,6 +215,18 @@ export const HttpEndpointRequestHeadersSchema = z.object({
"x-ts-http-headers": z.string().transform((s) => z.record(z.string()).parse(JSON.parse(s))),
});
+export const WebhookSourceRequestHeadersSchema = z.object({
+ "x-ts-key": z.string(),
+ "x-ts-dynamic-id": z.string().optional(),
+ "x-ts-secret": z.string(),
+ "x-ts-params": z.string().transform((s) => JSON.parse(s)),
+ "x-ts-http-url": z.string(),
+ "x-ts-http-method": z.string(),
+ "x-ts-http-headers": z.string().transform((s) => z.record(z.string()).parse(JSON.parse(s))),
+});
+
+export type WebhookSourceRequestHeaders = z.output;
+
export const PongSuccessResponseSchema = z.object({
ok: z.literal(true),
triggerVersion: z.string().optional(),
@@ -275,6 +329,26 @@ const SourceMetadataSchema = z.preprocess(
type SourceMetadata = Prettify>;
+export const WebhookMetadataSchema = z.object({
+ key: z.string(),
+ params: z.any(),
+ config: z.record(z.array(z.string())),
+ integration: IntegrationConfigSchema,
+ httpEndpoint: z.object({
+ id: z.string(),
+ }),
+});
+
+export type WebhookMetadata = z.infer;
+
+export const WebhookContextMetadataSchema = z.object({
+ params: z.any(),
+ config: z.record(z.string().array()),
+ secret: z.string(),
+});
+
+export type WebhookContextMetadata = z.infer;
+
export const DynamicTriggerEndpointMetadataSchema = z.object({
id: z.string(),
jobs: z.array(JobMetadataSchema.pick({ id: true, version: true })),
@@ -306,6 +380,7 @@ export type HttpEndpointMetadata = z.infer;
export const IndexEndpointResponseSchema = z.object({
jobs: z.array(JobMetadataSchema),
sources: z.array(SourceMetadataSchema),
+ webhooks: z.array(WebhookMetadataSchema),
dynamicTriggers: z.array(DynamicTriggerEndpointMetadataSchema),
dynamicSchedules: z.array(RegisterDynamicSchedulePayloadSchema),
httpEndpoints: z.array(HttpEndpointMetadataSchema).optional(),
@@ -323,6 +398,7 @@ export type EndpointIndexError = z.infer;
const IndexEndpointStatsSchema = z.object({
jobs: z.number(),
sources: z.number(),
+ webhooks: z.number(),
dynamicTriggers: z.number(),
dynamicSchedules: z.number(),
disabledJobs: z.number().default(0),
@@ -901,6 +977,14 @@ export const HttpSourceResponseSchema = z.object({
metadata: HttpSourceResponseMetadataSchema.optional(),
});
+export const WebhookDeliveryResponseSchema = z.object({
+ response: NormalizedResponseSchema,
+ verified: z.boolean(),
+ error: z.string().optional(),
+});
+
+export type WebhookDeliveryResponse = z.infer;
+
export const RegisterTriggerBodySchemaV1 = z.object({
rule: EventRuleSchema,
source: SourceMetadataV1Schema,
@@ -1028,3 +1112,28 @@ export const EphemeralEventDispatcherResponseBodySchema = z.object({
export type EphemeralEventDispatcherResponseBody = z.infer<
typeof EphemeralEventDispatcherResponseBodySchema
>;
+
+export const KeyValueStoreResponseBodySchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("DELETE"),
+ key: z.string(),
+ deleted: z.boolean(),
+ }),
+ z.object({
+ action: z.literal("GET"),
+ key: z.string(),
+ value: z.string().optional(),
+ }),
+ z.object({
+ action: z.literal("HAS"),
+ key: z.string(),
+ has: z.boolean(),
+ }),
+ z.object({
+ action: z.literal("SET"),
+ key: z.string(),
+ value: z.string().optional(),
+ }),
+]);
+
+export type KeyValueStoreResponseBody = z.infer;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 7d0697bf6..ae413ce93 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -2,3 +2,10 @@
export type Prettify = {
[K in keyof T]: T[K];
} & {};
+
+export interface AsyncMap {
+ delete: (key: string) => Promise;
+ get: (key: string) => Promise;
+ has: (key: string) => Promise;
+ set: (key: string, value: any) => Promise;
+}
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 7dd77cf5f..6af17bb49 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -31,3 +31,7 @@ export function deepMergeFilters(...filters: EventFilter[]): EventFilter {
return result;
}
+
+export function assertExhaustive(x: never): never {
+ throw new Error("Unexpected object: " + x);
+}
diff --git a/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql b/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql
index 5bcd79f8b..923e2c6f0 100644
--- a/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql
+++ b/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql
@@ -1,5 +1,7 @@
CREATE SCHEMA IF NOT EXISTS triggerdotdev_events;
+DROP TABLE IF EXISTS triggerdotdev_events.run_executions;
+
CREATE TABLE triggerdotdev_events.run_executions (
id SERIAL PRIMARY KEY,
organization_id TEXT NOT NULL,
diff --git a/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql b/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql
new file mode 100644
index 000000000..e13a18332
--- /dev/null
+++ b/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql
@@ -0,0 +1,56 @@
+-- CreateTable
+CREATE TABLE "Webhook" (
+ "id" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "params" JSONB,
+ "config" JSONB,
+ "desiredConfig" JSONB,
+ "httpEndpointId" TEXT NOT NULL,
+ "projectId" TEXT NOT NULL,
+ "environmentId" TEXT NOT NULL,
+ "integrationId" TEXT NOT NULL,
+ "active" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "KeyValueItem" (
+ "id" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "value" JSONB NOT NULL,
+ "environmentId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "KeyValueItem_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Webhook_httpEndpointId_key" ON "Webhook"("httpEndpointId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Webhook_key_projectId_key" ON "Webhook"("key", "projectId");
+
+-- CreateIndex
+CREATE INDEX "KeyValueItem_key_idx" ON "KeyValueItem" USING HASH ("key");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "KeyValueItem_environmentId_key_key" ON "KeyValueItem"("environmentId", "key");
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_httpEndpointId_fkey" FOREIGN KEY ("httpEndpointId") REFERENCES "TriggerHttpEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "Integration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "KeyValueItem" ADD CONSTRAINT "KeyValueItem_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql b/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql
new file mode 100644
index 000000000..7b00f8967
--- /dev/null
+++ b/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql
@@ -0,0 +1,38 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `config` on the `Webhook` table. All the data in the column will be lost.
+ - You are about to drop the column `desiredConfig` on the `Webhook` table. All the data in the column will be lost.
+ - You are about to drop the column `environmentId` on the `Webhook` table. All the data in the column will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_environmentId_fkey";
+
+-- AlterTable
+ALTER TABLE "Webhook" DROP COLUMN "config",
+DROP COLUMN "desiredConfig",
+DROP COLUMN "environmentId";
+
+-- CreateTable
+CREATE TABLE "WebhookEnvironment" (
+ "id" TEXT NOT NULL,
+ "active" BOOLEAN NOT NULL DEFAULT false,
+ "config" JSONB,
+ "desiredConfig" JSONB,
+ "environmentId" TEXT NOT NULL,
+ "webhookId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "WebhookEnvironment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "WebhookEnvironment_environmentId_webhookId_key" ON "WebhookEnvironment"("environmentId", "webhookId");
+
+-- AddForeignKey
+ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql b/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql
new file mode 100644
index 000000000..83af82ba1
--- /dev/null
+++ b/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - Changed the type of `value` on the `KeyValueItem` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+
+*/
+-- AlterTable
+ALTER TABLE "KeyValueItem" DROP COLUMN "value",
+ADD COLUMN "value" BYTEA NOT NULL;
diff --git a/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql b/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql
new file mode 100644
index 000000000..ef6ec17f3
--- /dev/null
+++ b/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql
@@ -0,0 +1,39 @@
+/*
+ Warnings:
+
+ - Added the required column `endpointId` to the `WebhookEnvironment` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "WebhookEnvironment" ADD COLUMN "endpointId" TEXT NOT NULL;
+
+-- CreateTable
+CREATE TABLE "WebhookRequestDelivery" (
+ "id" TEXT NOT NULL,
+ "url" TEXT NOT NULL,
+ "method" TEXT NOT NULL,
+ "headers" JSONB NOT NULL,
+ "body" BYTEA,
+ "verified" BOOLEAN NOT NULL DEFAULT false,
+ "error" TEXT,
+ "webhookId" TEXT NOT NULL,
+ "webhookEnvironmentId" TEXT NOT NULL,
+ "endpointId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "deliveredAt" TIMESTAMP(3),
+
+ CONSTRAINT "WebhookRequestDelivery_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "Endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_webhookEnvironmentId_fkey" FOREIGN KEY ("webhookEnvironmentId") REFERENCES "WebhookEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "Endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql b/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql
new file mode 100644
index 000000000..d715fba55
--- /dev/null
+++ b/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql
@@ -0,0 +1,11 @@
+/*
+ Warnings:
+
+ - Added the required column `environmentId` to the `WebhookRequestDelivery` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "WebhookRequestDelivery" ADD COLUMN "environmentId" TEXT NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql b/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql
new file mode 100644
index 000000000..ecd79ef78
--- /dev/null
+++ b/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql
@@ -0,0 +1,16 @@
+/*
+ Warnings:
+
+ - Added the required column `number` to the `WebhookRequestDelivery` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "WebhookRequestDelivery" ADD COLUMN "number" INTEGER NOT NULL;
+
+-- CreateTable
+CREATE TABLE "WebhookDeliveryCounter" (
+ "webhookId" TEXT NOT NULL,
+ "lastNumber" INTEGER NOT NULL DEFAULT 0,
+
+ CONSTRAINT "WebhookDeliveryCounter_pkey" PRIMARY KEY ("webhookId")
+);
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 46b459d4c..45ca92ebc 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -181,6 +181,7 @@ model Integration {
connections IntegrationConnection[]
jobIntegrations JobIntegration[]
sources TriggerSource[]
+ webhooks Webhook[]
missingConnections MissingConnection[]
RunConnection RunConnection[]
@@ -323,6 +324,9 @@ model RuntimeEnvironment {
scheduleSources ScheduleSource[]
ExternalAccount ExternalAccount[]
httpEndpointEnvironments TriggerHttpEndpointEnvironment[]
+ keyValueItems KeyValueItem[]
+ webhookEnvironments WebhookEnvironment[]
+ webhookRequestDeliveries WebhookRequestDelivery[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -354,6 +358,7 @@ model Project {
runs JobRun[]
sources TriggerSource[]
httpEndpoints TriggerHttpEndpoint[]
+ webhooks Webhook[]
}
model Endpoint {
@@ -386,10 +391,12 @@ model Endpoint {
jobVersions JobVersion[]
jobRuns JobRun[]
httpRequestDeliveries HttpSourceRequestDelivery[]
+ webhookRequestDeliveries WebhookRequestDelivery[]
dynamictriggers DynamicTrigger[]
sources TriggerSource[]
indexings EndpointIndex[]
httpEndpointEnvironments TriggerHttpEndpointEnvironment[]
+ webhookEnvironments WebhookEnvironment[]
@@unique([environmentId, slug])
}
@@ -1088,6 +1095,93 @@ model TriggerSourceOption {
@@unique([name, value, sourceId])
}
+model Webhook {
+ id String @id @default(cuid())
+
+ active Boolean @default(false)
+
+ key String
+ params Json?
+
+ webhookEnvironments WebhookEnvironment[]
+ requestDeliveries WebhookRequestDelivery[]
+
+ httpEndpoint TriggerHttpEndpoint @relation(fields: [httpEndpointId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ httpEndpointId String @unique
+
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ projectId String
+
+ integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ integrationId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([key, projectId])
+}
+
+model WebhookEnvironment {
+ id String @id @default(cuid())
+
+ active Boolean @default(false)
+
+ config Json?
+ desiredConfig Json?
+
+ requestDeliveries WebhookRequestDelivery[]
+
+ endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ endpointId String
+
+ environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ environmentId String
+
+ webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ webhookId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([environmentId, webhookId])
+}
+
+model WebhookRequestDelivery {
+ id String @id @default(cuid())
+ number Int
+
+ url String
+ method String
+ headers Json
+
+ body Bytes?
+
+ verified Boolean @default(false)
+ error String?
+
+ webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ webhookId String
+
+ environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ environmentId String
+
+ webhookEnvironment WebhookEnvironment @relation(fields: [webhookEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ webhookEnvironmentId String
+
+ endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ endpointId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ deliveredAt DateTime?
+}
+
+model WebhookDeliveryCounter {
+ webhookId String @id
+ lastNumber Int @default(0)
+}
+
model DynamicTriggerRegistration {
id String @id @default(cuid())
@@ -1173,6 +1267,8 @@ model TriggerHttpEndpoint {
icon String?
properties Json?
+ webhook Webhook?
+
secretReference SecretReference @relation(fields: [secretReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
secretReferenceId String
@@ -1219,6 +1315,22 @@ model TriggerHttpEndpointEnvironment {
@@unique([endpointId, httpEndpointId])
}
+model KeyValueItem {
+ id String @id @default(cuid())
+
+ key String
+ value Bytes
+
+ environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ environmentId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([environmentId, key])
+ @@index([key], type: Hash)
+}
+
model MissingConnection {
id String @id @default(cuid())
diff --git a/packages/integration-kit/src/types.ts b/packages/integration-kit/src/types.ts
index 20d24ef32..c57c4795f 100644
--- a/packages/integration-kit/src/types.ts
+++ b/packages/integration-kit/src/types.ts
@@ -1,3 +1,45 @@
-import type { FetchRetryOptions, FetchTimeoutOptions } from "@trigger.dev/core";
+export type { FetchRetryOptions, FetchTimeoutOptions } from "@trigger.dev/core";
-export type { FetchRetryOptions, FetchTimeoutOptions };
+export type Nullable = T extends Record
+ ? {
+ [K in keyof T]: T[K] | null;
+ }
+ : T | null;
+
+export type ObjectNonNullable = {
+ [K in keyof T]: K extends TKeys ? NonNullable : T[K];
+};
+
+export type SomeNonNullable, TSome extends keyof T> = {
+ [K in keyof T]: K extends TSome ? NonNullable : T[K];
+};
+
+export type SomeNullable, TSome extends keyof T> = {
+ [K in keyof T]: K extends TSome ? T[K] | null : T[K];
+};
+
+type FunctionKeys = {
+ [K in keyof T]: T[K] extends Function ? K : never;
+}[keyof T];
+
+export type OmitFunctions = {
+ [K in keyof T as Exclude>]: T[K];
+};
+
+export type OmitIndexSignature = {
+ [K in keyof T as {} extends Record ? never : K]: T[K];
+};
+
+type ObjectEntry = [keyof T, T[keyof T]];
+
+export type ObjectEntries = Array>;
+
+export type OmitValues, TValue extends any> = {
+ [K in keyof TRecord as TRecord[K] extends TValue ? never : K]: TRecord[K];
+};
+
+export type Optional, TOptional extends keyof TRecord> = Omit<
+ TRecord,
+ TOptional
+> &
+ Partial>;
diff --git a/packages/integration-kit/src/utils.ts b/packages/integration-kit/src/utils.ts
index cba3d7f25..27717e811 100644
--- a/packages/integration-kit/src/utils.ts
+++ b/packages/integration-kit/src/utils.ts
@@ -1,3 +1,28 @@
import { calculateResetAt } from "@trigger.dev/core";
+import { ObjectEntries } from "./types";
export const calculateResetAtUtil = calculateResetAt;
+
+export const entries = (object: T): ObjectEntries => {
+ return Object.entries(object) as ObjectEntries;
+};
+
+// see: https://github.com/sindresorhus/ts-extras
+export const fromEntries = Object.fromEntries as <
+ Key extends PropertyKey,
+ Entries extends ReadonlyArray,
+>(
+ values: Entries
+) => {
+ [K in Extract[0]]: Extract<
+ Entries[number],
+ readonly [K, unknown]
+ >[1];
+};
+
+export function titleCase(original: string): string {
+ return original
+ .split(" ")
+ .map((word) => word[0].toUpperCase() + word.slice(1))
+ .join(" ");
+}
diff --git a/packages/integration-kit/src/webhooks.ts b/packages/integration-kit/src/webhooks.ts
index 8a4e67944..8d0b586f7 100644
--- a/packages/integration-kit/src/webhooks.ts
+++ b/packages/integration-kit/src/webhooks.ts
@@ -14,3 +14,5 @@ export function safeParseBody(body: any) {
return body;
}
+
+export const registerJobNamespace = (webhookKey: string) => `job:webhook.register.${webhookKey}`;
diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json
index 66f6f0d6e..652f29e50 100644
--- a/packages/trigger-sdk/package.json
+++ b/packages/trigger-sdk/package.json
@@ -59,4 +59,4 @@
"engines": {
"node": ">=18.0.0"
}
-}
\ No newline at end of file
+}
diff --git a/packages/trigger-sdk/src/apiClient.ts b/packages/trigger-sdk/src/apiClient.ts
index bbe494245..18327326e 100644
--- a/packages/trigger-sdk/src/apiClient.ts
+++ b/packages/trigger-sdk/src/apiClient.ts
@@ -2,7 +2,6 @@ import {
ApiEventLog,
ApiEventLogSchema,
CancelRunsForEventSchema,
- CompleteTaskBodyInput,
ConnectionAuthSchema,
FailTaskBodyInput,
GetEventSchema,
@@ -36,9 +35,15 @@ import {
CompleteTaskBodyV2Input,
EphemeralEventDispatcherRequestBody,
EphemeralEventDispatcherResponseBodySchema,
+ UpdateWebhookBody,
+ KeyValueStoreResponseBodySchema,
+ KeyValueStoreResponseBody,
+ assertExhaustive,
+ HttpMethod,
} from "@trigger.dev/core";
import { z } from "zod";
+import { KeyValueStoreClient } from "./store/keyValueStoreClient";
export type ApiClientOptions = {
apiKey?: string;
@@ -73,12 +78,15 @@ export class ApiClient {
#apiUrl: string;
#options: ApiClientOptions;
#logger: Logger;
+ #storeClient: KeyValueStoreClient;
constructor(options: ApiClientOptions) {
this.#options = options;
this.#apiUrl = this.#options.apiUrl ?? process.env.TRIGGER_API_URL ?? "https://api.trigger.dev";
this.#logger = new Logger("trigger.dev", this.#options.logLevel);
+
+ this.#storeClient = new KeyValueStoreClient(this.#queryKeyValueStore.bind(this));
}
async registerEndpoint(options: { url: string; name: string }): Promise {
@@ -309,6 +317,25 @@ export class ApiClient {
return response;
}
+ async updateWebhook(key: string, webhookData: UpdateWebhookBody): Promise {
+ const apiKey = await this.#apiKey();
+
+ this.#logger.debug("activating webhook", {
+ webhookData,
+ });
+
+ const response = await zodfetch(TriggerSourceSchema, `${this.#apiUrl}/api/v1/webhooks/${key}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(webhookData),
+ });
+
+ return response;
+ }
+
async registerTrigger(
client: string,
id: string,
@@ -550,6 +577,88 @@ export class ApiClient {
return response;
}
+ get store() {
+ return this.#storeClient;
+ }
+
+ async #queryKeyValueStore(
+ action: KeyValueStoreResponseBody["action"],
+ data: {
+ key: string;
+ value?: string;
+ }
+ ): Promise {
+ const apiKey = await this.#apiKey();
+
+ this.#logger.debug("accessing key-value store", {
+ action,
+ data,
+ });
+
+ const STORE_URL = `${this.#apiUrl}/api/v1/store/${data.key}`;
+
+ const authHeader: HeadersInit = {
+ Authorization: `Bearer ${apiKey}`,
+ };
+
+ let requestInit: RequestInit | undefined;
+
+ switch (action) {
+ case "DELETE": {
+ requestInit = {
+ method: "DELETE",
+ headers: authHeader,
+ };
+
+ break;
+ }
+ case "GET": {
+ requestInit = {
+ method: "GET",
+ headers: authHeader,
+ };
+
+ break;
+ }
+ case "HAS": {
+ const headResponse = await fetchHead(STORE_URL, {
+ headers: authHeader,
+ });
+
+ return {
+ action: "HAS",
+ key: data.key,
+ has: !!headResponse.ok,
+ };
+ }
+ case "SET": {
+ const MAX_BODY_BYTE_LENGTH = 256 * 1024;
+
+ if ((data.value?.length ?? 0) > MAX_BODY_BYTE_LENGTH) {
+ throw new Error(`Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes`);
+ }
+
+ requestInit = {
+ method: "PUT",
+ headers: {
+ ...authHeader,
+ "Content-Type": "text/plain",
+ },
+ body: data.value,
+ };
+
+ break;
+ }
+ default: {
+ assertExhaustive(action);
+ }
+ }
+
+ const response = await zodfetch(KeyValueStoreResponseBodySchema, STORE_URL, requestInit);
+
+ return response;
+ }
+
async #apiKey() {
const apiKey = getApiKey(this.#options.apiKey);
@@ -716,6 +825,29 @@ async function zodfetchWithVersions<
};
}
+async function fetchHead(
+ url: string,
+ requestInitWithoutMethod?: Omit,
+ retryCount = 0
+): Promise {
+ const requestInit: RequestInit = {
+ ...requestInitWithoutMethod,
+ method: "HEAD",
+ };
+ const response = await fetch(url, { ...requestInit, cache: "no-cache" });
+
+ if (response.status >= 500 && retryCount < 6) {
+ // retry with exponential backoff and jitter
+ const delay = exponentialBackoff(retryCount + 1, 2, 50, 1150, 50);
+
+ await new Promise((resolve) => setTimeout(resolve, delay));
+
+ return fetchHead(url, requestInitWithoutMethod, retryCount + 1);
+ }
+
+ return response;
+}
+
async function zodfetch(
schema: TResponseSchema,
url: string,
diff --git a/packages/trigger-sdk/src/httpEndpoint.ts b/packages/trigger-sdk/src/httpEndpoint.ts
index 788fd40a1..4f5698e2f 100644
--- a/packages/trigger-sdk/src/httpEndpoint.ts
+++ b/packages/trigger-sdk/src/httpEndpoint.ts
@@ -124,7 +124,7 @@ type RespondWith = {
handler: (request: Request, verify: () => Promise) => Promise;
};
-type VerifyCallback = (request: Request) => Promise;
+export type VerifyCallback = (request: Request) => Promise;
export type EndpointOptions = {
/** Used to uniquely identify the HTTP Endpoint inside your Project. */
diff --git a/packages/trigger-sdk/src/io.ts b/packages/trigger-sdk/src/io.ts
index a01facfe4..43624411f 100644
--- a/packages/trigger-sdk/src/io.ts
+++ b/packages/trigger-sdk/src/io.ts
@@ -18,6 +18,7 @@ import {
SendEventOptions,
ServerTask,
UpdateTriggerSourceBodyV2,
+ UpdateWebhookBody,
supportsFeature,
} from "@trigger.dev/core";
import { BloomFilter } from "@trigger.dev/core-backend";
@@ -51,11 +52,13 @@ import {
waitForEventSchema,
} from "./types";
import { z } from "zod";
+import { KeyValueStore } from "./store/keyValueStore";
export type IOTask = ServerTask;
export type IOOptions = {
id: string;
+ jobId: string;
apiClient: ApiClient;
client: TriggerClient;
context: TriggerContext;
@@ -120,6 +123,7 @@ export type BackgroundFetchResponse = {
export class IO {
private _id: string;
+ private _jobId: string;
private _apiClient: ApiClient;
private _triggerClient: TriggerClient;
private _logger: Logger;
@@ -138,12 +142,17 @@ export class IO {
private _outputSerializer: OutputSerializer = new JSONOutputSerializer();
private _visitedCacheKeys: Set = new Set();
+ private _envStore: KeyValueStore;
+ private _jobStore: KeyValueStore;
+ private _runStore: KeyValueStore;
+
get stats() {
return this._stats;
}
constructor(options: IOOptions) {
this._id = options.id;
+ this._jobId = options.jobId;
this._apiClient = options.apiClient;
this._triggerClient = options.client;
this._logger = options.logger ?? new Logger("trigger.dev", options.logLevel);
@@ -153,6 +162,10 @@ export class IO {
this._timeOrigin = options.timeOrigin;
this._executionTimeout = options.executionTimeout;
+ this._envStore = new KeyValueStore(options.apiClient);
+ this._jobStore = new KeyValueStore(options.apiClient, "job", options.jobId);
+ this._runStore = new KeyValueStore(options.apiClient, "run", options.id);
+
this._stats = {
initialCachedTasks: 0,
lazyLoadedCachedTasks: 0,
@@ -714,8 +727,9 @@ export class IO {
return await this._triggerClient.sendEvent(event, options);
},
{
- name: "sendEvent",
+ name: "Send Event",
params: { event, options },
+ icon: "send",
properties: [
{
label: "name",
@@ -740,8 +754,9 @@ export class IO {
return await this._triggerClient.sendEvents(events, options);
},
{
- name: "sendEvents",
+ name: "Send Multiple Events",
params: { events, options },
+ icon: "send",
properties: [
{
label: "Total Events",
@@ -824,6 +839,26 @@ export class IO {
);
}
+ async updateWebhook(cacheKey: string | any[], options: { key: string } & UpdateWebhookBody) {
+ return this.runTask(
+ cacheKey,
+ async (task) => {
+ return await this._apiClient.updateWebhook(options.key, options);
+ },
+ {
+ name: "Update Webhook Source",
+ icon: "refresh",
+ properties: [
+ {
+ label: "key",
+ text: options.key,
+ },
+ ],
+ params: options,
+ }
+ );
+ }
+
/** `io.registerInterval()` allows you to register a [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) that will trigger any jobs it's attached to on a regular interval.
* @param cacheKey Should be a stable and unique key inside the `run()`. See [resumability](https://trigger.dev/docs/documentation/concepts/resumability) for more information.
* @param dynamicSchedule The [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) to register a new schedule on.
@@ -1387,6 +1422,14 @@ export class IO {
}
}
+ get store() {
+ return {
+ env: this._envStore,
+ job: this._jobStore,
+ run: this._runStore,
+ };
+ }
+
#addToCachedTasks(task: ServerTask) {
this._cachedTasks.set(task.idempotencyKey, task);
}
diff --git a/packages/trigger-sdk/src/security.ts b/packages/trigger-sdk/src/security.ts
index 663437d6b..eaf1da0af 100644
--- a/packages/trigger-sdk/src/security.ts
+++ b/packages/trigger-sdk/src/security.ts
@@ -1,10 +1,12 @@
import crypto from "crypto";
+import type { BinaryToTextEncoding, BinaryLike, KeyObject } from "crypto";
import { VerifyResult } from "./types";
/** Easily verify webhook payloads when they're using common signing methods. */
export async function verifyRequestSignature({
request,
headerName,
+ headerEncoding = "hex",
secret,
algorithm,
}: {
@@ -12,9 +14,11 @@ export async function verifyRequestSignature({
request: Request;
/** The name of the header that contains the signature. E.g. `X-Cal-Signature-256`. */
headerName: string;
+ /** The header encoding. Defaults to `hex`. */
+ headerEncoding?: BinaryToTextEncoding;
/** The secret that you use to hash the payload. For HttpEndpoints this will usually originally
come from the Trigger.dev dashboard and should be stored in an environment variable. */
- secret: string;
+ secret: BinaryLike | KeyObject;
/** The hashing algorithm that was used to create the signature. Currently only `sha256` is
supported. */
algorithm: "sha256";
@@ -33,7 +37,7 @@ export async function verifyRequestSignature({
switch (algorithm) {
case "sha256":
- const success = verifyHmacSha256(headerValue, secret, await request.text());
+ const success = verifyHmacSha256(headerValue, headerEncoding, secret, await request.text());
if (success) {
return {
@@ -47,9 +51,14 @@ export async function verifyRequestSignature({
}
}
-export function verifyHmacSha256(headerValue: string, secret: string, body: string): boolean {
- const bodyDigest = crypto.createHmac("sha256", secret).update(body).digest("hex");
- const signature = headerValue?.replace("sha256=", "") ?? "";
+export function verifyHmacSha256(
+ headerValue: string,
+ headerEncoding: BinaryToTextEncoding,
+ secret: BinaryLike | KeyObject,
+ body: string
+): boolean {
+ const bodyDigest = crypto.createHmac("sha256", secret).update(body).digest(headerEncoding);
+ const signature = headerValue?.replace("hmac-sha256=", "").replace("sha256=", "") ?? "";
return signature === bodyDigest;
}
diff --git a/packages/trigger-sdk/src/store/keyValueStore.ts b/packages/trigger-sdk/src/store/keyValueStore.ts
new file mode 100644
index 000000000..6d112eeef
--- /dev/null
+++ b/packages/trigger-sdk/src/store/keyValueStore.ts
@@ -0,0 +1,196 @@
+import { ApiClient } from "../apiClient";
+import { Json } from "../io";
+import { runLocalStorage } from "../runLocalStorage";
+
+export class KeyValueStore {
+ constructor(
+ private apiClient: ApiClient,
+ private type: string | null = null,
+ private namespace: string = ""
+ ) {}
+
+ #namespacedKey(key: string) {
+ const parts = [];
+
+ if (this.type) {
+ parts.push(this.type);
+ }
+
+ if (this.namespace) {
+ parts.push(this.namespace);
+ }
+
+ parts.push(key);
+
+ return parts.join(":");
+ }
+
+ #sharedProperties(key: string) {
+ return [
+ {
+ label: "namespace",
+ text: this.type ?? "env",
+ },
+ {
+ label: "key",
+ text: key,
+ },
+ ];
+ }
+
+ async delete(cacheKey: string | any[], key: string): Promise;
+ async delete(key: string): Promise;
+ async delete(param1: string | any[], param2?: string): Promise {
+ const runStore = runLocalStorage.getStore();
+
+ if (!runStore) {
+ if (typeof param1 !== "string") {
+ throw new Error(
+ "Please use the store without a cacheKey when accessing from outside a run."
+ );
+ }
+
+ return await this.apiClient.store.delete(this.#namespacedKey(param1));
+ }
+
+ const { io } = runStore;
+
+ if (!param2) {
+ throw new Error("Please provide a non-empty key when accessing the store from inside a run.");
+ }
+
+ return await io.runTask(
+ param1,
+ async (task) => {
+ return await this.apiClient.store.delete(this.#namespacedKey(param2));
+ },
+ {
+ name: "Key-Value Store Delete",
+ icon: "database-minus",
+ params: { key: param2 },
+ properties: this.#sharedProperties(param2),
+ style: { style: "minimal" },
+ }
+ );
+ }
+
+ async get = any>(cacheKey: string | any[], key: string): Promise;
+ async get = any>(key: string): Promise;
+ async get = any>(param1: string | any[], param2?: string): Promise {
+ const runStore = runLocalStorage.getStore();
+
+ if (!runStore) {
+ if (typeof param1 !== "string") {
+ throw new Error(
+ "Please use the store without a cacheKey when accessing from outside a run."
+ );
+ }
+
+ return await this.apiClient.store.get(this.#namespacedKey(param1));
+ }
+
+ const { io } = runStore;
+
+ if (!param2) {
+ throw new Error("Please provide a non-empty key when accessing the store from inside a run.");
+ }
+
+ return await io.runTask(
+ param1,
+ async (task) => {
+ return await this.apiClient.store.get(this.#namespacedKey(param2));
+ },
+ {
+ name: "Key-Value Store Get",
+ icon: "database-export",
+ params: { key: param2 },
+ properties: this.#sharedProperties(param2),
+ style: { style: "minimal" },
+ }
+ );
+ }
+
+ async has(cacheKey: string | any[], key: string): Promise;
+ async has(key: string): Promise;
+ async has(param1: string | any[], param2?: string): Promise {
+ const runStore = runLocalStorage.getStore();
+
+ if (!runStore) {
+ if (typeof param1 !== "string") {
+ throw new Error(
+ "Please use the store without a cacheKey when accessing from outside a run."
+ );
+ }
+
+ return await this.apiClient.store.has(this.#namespacedKey(param1));
+ }
+
+ const { io } = runStore;
+
+ if (!param2) {
+ throw new Error("Please provide a non-empty key when accessing the store from inside a run.");
+ }
+
+ return await io.runTask(
+ param1,
+ async (task) => {
+ return await this.apiClient.store.has(this.#namespacedKey(param2));
+ },
+ {
+ name: "Key-Value Store Has",
+ icon: "database-search",
+ params: { key: param2 },
+ properties: this.#sharedProperties(param2),
+ style: { style: "minimal" },
+ }
+ );
+ }
+
+ async set>(cacheKey: string | any[], key: string, value: T): Promise;
+ async set>(key: string, value: T): Promise;
+ async set>(param1: string | any[], param2: string | T, param3?: T): Promise {
+ const runStore = runLocalStorage.getStore();
+
+ if (!runStore) {
+ if (typeof param1 !== "string") {
+ throw new Error(
+ "Please use the store without a cacheKey when accessing from outside a run."
+ );
+ }
+
+ return await this.apiClient.store.set(this.#namespacedKey(param1), param2 as T);
+ }
+
+ const { io } = runStore;
+
+ if (!param2 || typeof param2 !== "string") {
+ throw new Error("Please provide a non-empty key when accessing the store from inside a run.");
+ }
+
+ const value = param3 as T;
+
+ return await io.runTask(
+ param1,
+ async (task) => {
+ return await this.apiClient.store.set(this.#namespacedKey(param2), value);
+ },
+ {
+ name: "Key-Value Store Set",
+ icon: "database-plus",
+ params: { key: param2, value },
+ properties: [
+ ...this.#sharedProperties(param2),
+ ...(typeof value !== "object" || value === null
+ ? [
+ {
+ label: "value",
+ text: String(value) ?? "undefined",
+ },
+ ]
+ : []),
+ ],
+ style: { style: "minimal" },
+ }
+ );
+ }
+}
diff --git a/packages/trigger-sdk/src/store/keyValueStoreClient.ts b/packages/trigger-sdk/src/store/keyValueStoreClient.ts
new file mode 100644
index 000000000..e294bbcd4
--- /dev/null
+++ b/packages/trigger-sdk/src/store/keyValueStoreClient.ts
@@ -0,0 +1,86 @@
+import { AsyncMap } from "@trigger.dev/core";
+import { KeyValueStoreResponseBody } from "@trigger.dev/core";
+import { JSONOutputSerializer, Json } from "../io";
+
+type QueryKeyValueStoreFunction = (
+ action: "DELETE" | "GET" | "HAS" | "SET",
+ data: {
+ key: string;
+ value?: string;
+ }
+) => Promise;
+
+export class KeyValueStoreClient implements AsyncMap {
+ #serializer = new JSONOutputSerializer();
+
+ constructor(
+ private queryStore: QueryKeyValueStoreFunction,
+ private type: string | null = null,
+ private namespace: string = ""
+ ) {}
+
+ #namespacedKey(key: string) {
+ const parts = [];
+
+ if (this.type) {
+ parts.push(this.type);
+ }
+
+ if (this.namespace) {
+ parts.push(this.namespace);
+ }
+
+ parts.push(key);
+
+ return parts.join(":");
+ }
+
+ async delete(key: string): Promise {
+ const result = await this.queryStore("DELETE", {
+ key: this.#namespacedKey(key),
+ });
+
+ if (result.action !== "DELETE") {
+ throw new Error(`Unexpected key-value store response: ${result.action}`);
+ }
+
+ return result.deleted;
+ }
+
+ async get>(key: string): Promise {
+ const result = await this.queryStore("GET", {
+ key: this.#namespacedKey(key),
+ });
+
+ if (result.action !== "GET") {
+ throw new Error(`Unexpected key-value store response: ${result.action}`);
+ }
+
+ return this.#serializer.deserialize(result.value);
+ }
+
+ async has(key: string): Promise {
+ const result = await this.queryStore("HAS", {
+ key: this.#namespacedKey(key),
+ });
+
+ if (result.action !== "HAS") {
+ throw new Error(`Unexpected key-value store response: ${result.action}`);
+ }
+
+ return result.has;
+ }
+
+ async set>(key: string, value: T): Promise