How to Update App Webhooks
Problem​
Imagine you have an app that notifies an external service whenever a new order is created. The app registers the ORDER_CREATED webhook based on the subscription webhook payload from the OrderCreatedSubscription.graphql file:
# OrderCreatedSubscription.graphql
subscription OrderCreated {
event {
... on OrderCreated {
order {
id
created
number
}
}
}
}
The subscription defines what data will be sent to the app along with the webhook. The app can then execute logic utilizing the subscription payload. In our case, it will be notifying the external service about the new order:
// pseudo-code for order-created webhook handler
const payload = {
id: order.id,
created: order.created,
number: order.number,
};
service.notifyOrderCreated(payload);
After some time, you realize you need an additional order field: status. In the next app release, you add it to the subscription webhook payload:
# OrderCreatedSubscription.graphql
subscription OrderCreated {
event {
... on OrderCreated {
order {
id
created
number
status
}
}
}
}
You modify the code to use the new field:
// pseudo-code for order-created webhook handler
const payload = {
id: order.id,
created: order.created,
number: order.number,
status: order.status,
};
service.notifyOrderCreated(payload);
You deploy the app and trigger the ORDER_CREATED webhook. Perhaps surprised, you see a type error: order.status is not defined. What happened?
Although you did modify both the OrderCreatedSubscription.graphql file, and the code of your application, VapeEcommerce still uses the original subscription from the app installation manifest.
Whenever your app starts using a new field from the subscription, you must update the app's webhook query field.
If your app hasn't yet been released or does not require zero downtime, the easiest solution would be to simply reinstall it. The query used in the subscription will be regenerated during the installation.
However, if your app has to stay functional at all times, you have to programmatically update its webhooks.
Webhook migration script​
To update webhooks without disrupting service you could use a webhook migration script. Those scripts should, ideally, execute before your app deployment (e.g., in CI). This way, you can ensure the app has all the required subscription fields without downtime.
If you are using @saleor/app-sdk for app development, you can use some of its helpers in your migration script.
Here is what a webhook migration script may look like:
1. Authenticate app​
To authenticate app-related API calls, you need to get the app token from its authData.
Where you get authData will depend on your APL implementation.
Let's assume you are using the UpstashAPL provided in app-sdk:
import { UpstashAPL } from "@saleor/app-sdk/APL";
export const getAppAuthData = async () => {
// Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
// Initialize UpstashAPL
const apl = new UpstashAPL();
// Get authData of all registered apps
const apps = await apl.getAll();
// Assuming there is only one app, return its authData
return apps[0];
};
2. Get a webhook manifest​
We will update our webhook with webhookUpdate mutation. As its input, we only want to pass the new value of the query field.
We can get a stringified query field from the webhook manifest. Webhook manifest is the result of executing getWebhookManifest method on the instance of SaleorAsyncWebhook or SaleorSyncWebhook classes. These classes are provided by the @saleor/app-sdk to help you build your webhooks for the app manifest.
Here is what webhook handler may look like for an ORDER_CREATED webhook:
// api/webhooks/order-created.ts
import { saleorApp } from "@/saleor-app";
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCreatedDocument,
OrderCreatedSubscriptionPayloadFragment,
} from "@/generated/graphql";
export const orderCreatedWebhook =
new SaleorAsyncWebhook<OrderCreatedSubscriptionPayloadFragment>({
name: "Order Created",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
apl: saleorApp.apl,
query: OrderCreatedDocument, // OrderCreatedDocument will be converted into a string query
});
// Stable, app-defined identifier used to reference this webhook in the migration script
export const ORDER_CREATED_WEBHOOK_IDENTIFIER = "order-created";
You can see getWebhookManifest being used in the webhooks field of your manifest.ts, where the app manifest is created. VapeEcommerce registers the app based on this manifest.
Add an identifier to the manifest entry so VapeEcommerce stores it when the app is installed. getWebhookManifest does not set one, so spread its result and add the field:
// pages/api/manifest.ts
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types";
import {
orderCreatedWebhook,
ORDER_CREATED_WEBHOOK_IDENTIFIER,
} from "./webhooks/order-created";
export default createManifestHandler({
async manifestFactory({ appBaseUrl }) {
const manifest = {
// ...
webhooks: [
{
...orderCreatedWebhook.getWebhookManifest(appBaseUrl),
identifier: ORDER_CREATED_WEBHOOK_IDENTIFIER,
},
],
};
return manifest;
},
});
Requires VapeEcommerce 3.23.23 or newer for the manifest and Webhook.identifier field, and @saleor/app-sdk with identifier on the WebhookManifest type. On older VapeEcommerce versions the field is ignored, and the migration script has to fall back to matching by name — see Apps installed before identifiers.
We will repeat the same logic in our migration script to get the current state of our webhook:
import { orderCreatedWebhook } from "./pages/api/webhooks/order-created";
const runMigration = async () => {
const authData = await getAppAuthData();
// Regenerate orderCreated webhook manifest with updated state
const webhookManifest = orderCreatedWebhook.getWebhookManifest(
authData.saleorApiUrl
);
};
3. Update the webhook​
Because the webhook declares an identifier, the script can address it directly — webhookUpdate accepts identifier in place of id from VapeEcommerce 3.23.27, so there is no lookup step:
mutation UpdateWebhookQuery($identifier: String!, $query: String!) {
webhookUpdate(identifier: $identifier, input: { query: $query }) {
webhook {
id
identifier
}
errors {
field
code
message
}
}
}
In the next code example, we will assume the existence of:
createGraphQLClient- a function that returns a GraphQL clientAppWebhookManager- a class that takes in the GraphQL client and makes calls to the VapeEcommerce API. It has the following methods:updateWebhookQuery- runs thewebhookUpdatemutation above, addressing the webhook byidentifiergetOwnWebhooks- runs theappquery and returns the app's ownwebhookssetWebhookIdentifier- runswebhookUpdatebyid, writing theidentifierinput
Neither is provided by VapeEcommerce or @saleor/app-sdk — they stand in for whatever GraphQL client and wrapper your app already uses.
Once we have the up-to-date manifest, we can retrieve the stringified query from it and update the webhook:
import { ORDER_CREATED_WEBHOOK_IDENTIFIER } from "./pages/api/webhooks/order-created";
const runMigration = async () => {
// ...
// Imaginary function that creates a GraphQL client for your API calls. This can be Apollo Client, Urql Client, etc.
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
// Imaginary class that takes in GraphQL client and makes calls to the VapeEcommerce API
const appWebhookManager = new AppWebhookManager({
client,
});
// Update webhook with new query, addressing it by its stable identifier
await appWebhookManager.updateWebhookQuery({
identifier: ORDER_CREATED_WEBHOOK_IDENTIFIER,
query: webhookManifest.query, // update webhook with fields from the new manifest
});
};
The identifier argument is available exclusively to an app referencing its own webhook — the app token in the Authorization header is what scopes the lookup. If no webhook of that app matches, the mutation returns a NOT_FOUND error on the identifier field.
Apps installed before identifiers​
identifier is only stored at install time, so webhooks of apps installed before the identifier was added to the manifest have none. Those apps need a one-time backfill: list the app's own webhooks, match them by whatever you used previously (usually name), and write the identifier once.
The app's own webhooks are read through the app query — with an app token in the Authorization header it resolves to the calling app, so no ID is needed:
query AppWebhooks {
app {
webhooks {
id
name
identifier
}
}
}
Writing the identifier is a normal webhookUpdate addressed by id, since there is no identifier to address it by yet:
mutation SetWebhookIdentifier($id: ID!, $identifier: String!) {
webhookUpdate(id: $id, input: { identifier: $identifier }) {
webhook {
id
identifier
}
errors {
field
code
message
}
}
}
Using the same appWebhookManager as in step 3:
const backfillIdentifier = async () => {
const webhooks = await appWebhookManager.getOwnWebhooks();
const webhook = webhooks.find(
(w) => !w.identifier && w.name === webhookManifest.name
);
if (webhook) {
await appWebhookManager.setWebhookIdentifier({
id: webhook.id,
identifier: ORDER_CREATED_WEBHOOK_IDENTIFIER,
});
}
};
Run this before the first identifier-based migration. Afterwards the name match is no longer needed, and renaming the webhook in the Dashboard stops breaking your migrations.
Matching on name is exactly the fragile step identifier removes: name is a display label that a staff user can change in the Dashboard, and nothing stops two webhooks of the same app from sharing it. Treat the backfill as a one-off, not as a permanent fallback.
Next steps​
For most cases, the above script should be enough to update the webhook. However, if you can't afford to have any downtime, you should consider a more complex migration process that factors in:
- Rollback - If the migration fails, you should be able to roll back to the previous state. This can be achieved by deactivating the old webhook, creating a new webhook, testing it, and only then removing the old one.
- Queued events - VapeEcommerce puts events in a queue and processes them asynchronously. This means that even if you update the webhook, the events that were put in the queue before will still be sent with the old query. Your app should be able to work with both old and new queries until the migration is complete.
- Safe query modification - If you add a new field to the query, you should ensure that the app can handle the absence of this field in the old events. If you remove a field, you should ensure that the app can handle the presence of this field in the old events.
If you need the full-picture view of migration scripts, feel free to peak into saleor/apps repository.