
* Change process-async-saleor-webhook to process-saleor-webhook and add better debug logs * Change processWebhook name * wip * Add base class structure * refactor * add sync payload builder * wip * wip * Add some missing webhook responses * Fix tests for Async version * Fix tests * Add MockAPL test util * Refactor tests to use MockAPL * Add test to sync webhook * Restore legacy subscriptionQueryAst field for compatibility * CR Fixes - TS type & event -> eventType * Changelog, docs and remove old payment webhooks * Apply suggestions from code review Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com> * Update src/handlers/next/saleor-webhooks/process-saleor-webhook.ts Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com> --------- Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
8.4 KiB
Saleor Webhook utilities
Apps are usually connected via webhooks - one App sends an HTTP request to another App, informing about some event or requesting some action to be performed.
To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request.
The App SDK provides a utility that abstracts connection details and auth, allowing developers to focus on business logic.
Depending on the type of the webhook, you can choose one of the classes:
SaleorAsyncWebhook
SaleorSyncWebhook
Common configuration
Both SaleorSyncWebhook
and SaleorAsyncWebhook
contain similar API with little differences.
Constructing Webhook instance
In Next.js pages create a page, e.g. pages/api/webhooks/order-created.ts
. We recommend to keep webhook type in file name, which will be resolved by Next.js to route path.
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>(options);
For SaleorSyncWebhook
it will be similar. Create e.g. order-calculate-taxes.ts
page and create a new instance:
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type CalculateTaxedPayload = {
// taxes payload from subscription
};
export const orderCalculateTaxesWebhook = new SaleorSyncWebhook<OrderPayload>(options);
Configuring Webhook instance
options
in constructor must be specified. Here are all options:
type Options = {
/**
* Additional webhook name, optional.
*/
name?: string;
/**
* Path to webhook. Should represent relative path from base app URL. In Next.js it will start with `api/`, e.g. `api/webhooks/order-created`.
*/
webhookPath: string;
/**
* Valid Async or Sync webhook. Constructor is statically typed so only valid Sync/Async webhooks will be allowed
*/
event: Event;
/**
* Should event be active after installation, enabled by default
*/
isActive?: boolean;
/**
* APL instance - see docs/apl/md
*/
apl: APL;
/**
* Optional callback that allows to inject custom error handling and take control of the response
*/
onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void;
/**
* Optional callback that allows to format error message. Useful to control how much details should be returned in the response
*/
formatErrorResponse?(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
): Promise<{
code: number;
body: object | string;
}>;
/**
* Required subscription query. Can be raw GraphQL string or the instance of query wrapped in `gql` tags
*/
query: string | ASTNode;
};
Configuration examples
SaleorAsyncWebhook configuration example
// pages/api/webhooks/order-created.ts
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
name: "Order Created",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
isActive: true,
apl: require("../lib/apl"),
query: `
subscription {
event {
... on OrderCreated {
order {
id
}
}
}
}
`,
onError(error: WebhookError | Error) {
// Can be used to e.g. trace errors
sentry.captureError(error);
},
async formatErrorResponse(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
) {
return {
code: 400,
body: "My custom response",
};
},
});
SyncAsyncWebhook configuration example
// pages/api/webhooks/order-created.ts
/**
* To be type safe, define payload from API. This should be imported from generated graphQL code
*/
type Payload = {
taxBase: {
currency: string;
};
};
export const orderCalculateTaxesWebhook = new SaleorAsyncWebhook<Payload>({
name: "Order Calculate Taxes",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CALCULATE_TAXES",
isActive: true,
apl: require("../lib/apl"),
query: `
subscription {
event {
... on CalculateTaxes {
taxBase {
currency
}
}
}
}
`,
onError(error: WebhookError | Error) {
sentry.captureError(error);
},
async formatErrorResponse(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
) {
return {
code: 400,
body: "My custom response",
};
},
});
Resources
- Check available events here
- Read more about APLs
- Subscription query documentation
Extending app manifest
Webhooks are created in Saleor when the App is installed. Saleor uses AppManifest to get information about webhooks to create.
SaleorSyncWebhook
& SaleorAsyncWebhook
utility can generate this manifest:
// pages/api/manifest
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { orderCreatedWebhook } from "./order-created.ts";
export default createManifestHandler({
manifestFactory({ appBaseUrl }) {
return {
/**
* Add one or more webhook manifests.
*/
webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)],
// ...rest of your App's manifest
};
},
});
Now, try to read your manifest, in default Next.js config it will be GET localhost:3000/api/manifest
. You should see webhook configuration as part of manifest response.
Creating webhook domain logic
Now, let's create a handler that will process webhook data. Let's back to handler file pages/api/webhooks/order-created.ts
.
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
// ... your configuration
});
/**
* Handler has to be a default export so the Next.js will be able to use it
*/
export default orderCreatedWebhook.createHandler((req, res, context) => {
const { baseUrl, event, payload, authData } = context;
console.log(payload.id); // type is inferred
// Perform some domain logic
// End with status 200
return res.status(200).end();
});
Typed sync webhook response
Sync webhooks need to return response to Saleor, so operation can be completed. To achieve that, SaleorAsyncWebhook
injects additional context field buildResponse
.
It infers even from constructor and provides typed factory:
const webhook = new SaleorAsyncWebhook({ event: "ORDER_CALCULATE_TAXES" /* ... rest of config */ });
orderCreatedWebhook.createHandler((req, res, context) => {
return res.status(200).send(
context.buildResponse({
// Fields are typed here
})
);
});
query vs subscriptionQueryAst
Subscription query can be specified using plain string or as ASTNode
object created by gql
tag.
If your project does not use any code generation for GraphQL operations, use the string. In case you are using GraphQL Code Generator, which we highly recommend, you should pass a subscription as GraphQL ASTNode:
/**
* Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file.
*/
export const ExampleProductUpdatedSubscription = gql`
${ProductUpdatedWebhookPayload}
subscription ExampleProductUpdated {
event {
fragment
ProductUpdatedWebhookPayload
on
ProductUpdated {
product {
id
name
}
}
}
}
`;
export const productUpdatedWebhook = new SaleorAsyncWebhook<ProductUpdatedWebhookPayloadFragment>({
name: "Example product updated webhook",
webhookPath: "api/webhooks/saleor/product-updated",
event: "PRODUCT_UPDATED",
apl: saleorApp.apl,
query: ExampleProductUpdatedSubscription, // Or use plain string
});