Webhooks are critical for subscription apps to respond to events that occur outside the app’s control. This guide covers all webhook implementations in the Shopify Subscriptions Reference App.
Webhook Configuration
Webhooks are configured in shopify.app.toml:
[ webhooks ]
api_version = "unstable"
# App lifecycle
[[ webhooks . subscriptions ]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app/uninstalled"
# Contract lifecycle
[[ webhooks . subscriptions ]]
topics = [ "subscription_contracts/create" ]
uri = "/webhooks/subscription_contracts/create"
[[ webhooks . subscriptions ]]
topics = [ "subscription_contracts/activate" ]
uri = "/webhooks/subscription_contracts/activate"
[[ webhooks . subscriptions ]]
topics = [ "subscription_contracts/cancel" ]
uri = "/webhooks/subscription_contracts/cancel"
[[ webhooks . subscriptions ]]
topics = [ "subscription_contracts/pause" ]
uri = "/webhooks/subscription_contracts/pause"
# Billing cycle events
[[ webhooks . subscriptions ]]
topics = [ "subscription_billing_cycles/skip" ]
uri = "/webhooks/subscription_billing_cycles/skip"
[[ webhooks . subscriptions ]]
topics = [ "subscription_billing_attempts/success" ]
uri = "/webhooks/subscription_billing_attempts/success"
[[ webhooks . subscriptions ]]
topics = [ "subscription_billing_attempts/failure" ]
uri = "/webhooks/subscription_billing_attempts/failure"
# Selling plan events
[[ webhooks . subscriptions ]]
topics = [ "selling_plan_groups/create" , "selling_plan_groups/update" ]
uri = "/webhooks/selling_plan_groups/create_or_update"
# GDPR compliance
[[ webhooks . subscriptions ]]
uri = "/webhooks/customer_redact"
compliance_topics = [ "customers/data_request" , "customers/redact" ]
[[ webhooks . subscriptions ]]
uri = "/webhooks/shop_redact"
compliance_topics = [ "shop/redact" ]
The Shopify CLI automatically registers these webhooks when you run shopify app dev or deploy your app.
Webhook Handler Structure
All webhook handlers follow this pattern:
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Handle webhook logic here
return new Response ();
};
Contract Creation Webhook
Triggered when a new subscription contract is created.
File: app/routes/webhooks.subscription_contracts.create.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
import { CustomerSendEmailJob , jobs , TagSubscriptionOrderJob } from '~/jobs' ;
import { CustomerEmailTemplateName } from '~/services/CustomerSendEmailService' ;
import type { Jobs , Webhooks } from '~/types' ;
import { FIRST_ORDER_TAGS } from '~/jobs/tags/constants' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
const { admin_graphql_api_origin_order_id : orderId } = payload ;
// Send welcome email if contract was created from checkout
if ( orderIsFromCheckout ( orderId )) {
const emailParams : Jobs . Parameters < Webhooks . SubscriptionContractEvent > = {
shop ,
payload: {
... ( payload as Webhooks . SubscriptionContractsCreate ),
emailTemplate: CustomerEmailTemplateName . NewSubscription ,
},
};
jobs . enqueue ( new CustomerSendEmailJob ( emailParams ));
}
// Tag the origin order
const tagParams : Jobs . Parameters < Jobs . TagSubscriptionsOrderPayload > = {
shop ,
payload: {
orderId: payload . admin_graphql_api_origin_order_id ,
tags: FIRST_ORDER_TAGS ,
},
};
jobs . enqueue ( new TagSubscriptionOrderJob ( tagParams ));
return new Response ();
};
function orderIsFromCheckout ( orderId : string | null ) : boolean {
return orderId !== null ;
}
Key actions:
Send a welcome email to the customer
Tag the origin order as a subscription order
Enqueue background jobs for processing
Billing Attempt Success Webhook
Triggered when a subscription billing attempt succeeds.
File: app/routes/webhooks.subscription_billing_attempts.success.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { DunningStopJob , jobs , TagSubscriptionOrderJob } from '~/jobs' ;
import { RECURRING_ORDER_TAGS } from '~/jobs/tags/constants' ;
import { authenticate } from '~/shopify.server' ;
import type { Webhooks } from '~/types' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Stop any active dunning processes
jobs . enqueue (
new DunningStopJob ({
shop ,
payload: payload as Webhooks . SubscriptionBillingAttemptSuccess ,
})
);
// Tag the recurring order
jobs . enqueue (
new TagSubscriptionOrderJob ({
shop ,
payload: {
orderId: payload . admin_graphql_api_order_id ,
tags: RECURRING_ORDER_TAGS ,
},
})
);
return new Response ();
};
Key actions:
Stop dunning (payment retry) processes
Tag the order as a recurring subscription order
Resume normal subscription flow
Billing Attempt Failure Webhook
Triggered when a subscription billing attempt fails.
File: app/routes/webhooks.subscription_billing_attempts.failure.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { DunningStartJob , jobs } from '~/jobs' ;
import { authenticate } from '~/shopify.server' ;
import type { Webhooks } from '~/types' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
jobs . enqueue (
new DunningStartJob ({
shop ,
payload: payload as Webhooks . SubscriptionBillingAttemptFailure ,
})
);
return new Response ();
};
Key actions:
Start dunning process to retry payment
Track failed billing attempts
Notify customer of payment failure
Contract Activate Webhook
Triggered when a subscription contract is activated.
File: app/routes/webhooks.subscription_contracts.activate.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Add custom logic here
// Examples:
// - Send activation email
// - Update external systems
// - Trigger analytics events
return new Response ();
};
Contract Pause Webhook
Triggered when a subscription contract is paused.
File: app/routes/webhooks.subscription_contracts.pause.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Add custom logic here
// Examples:
// - Send pause confirmation email
// - Update inventory forecasts
// - Notify fulfillment systems
return new Response ();
};
Contract Cancel Webhook
Triggered when a subscription contract is cancelled.
File: app/routes/webhooks.subscription_contracts.cancel.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
import { jobs , CustomerSendEmailJob } from '~/jobs' ;
import { CustomerEmailTemplateName } from '~/services/CustomerSendEmailService' ;
import type { Webhooks } from '~/types' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Send cancellation confirmation email
const emailParams = {
shop ,
payload: {
... ( payload as Webhooks . SubscriptionContractsCancel ),
emailTemplate: CustomerEmailTemplateName . CancellationConfirmation ,
},
};
jobs . enqueue ( new CustomerSendEmailJob ( emailParams ));
// Add additional logic:
// - Update customer lifetime value
// - Trigger win-back campaigns
// - Update analytics
return new Response ();
};
Billing Cycle Skip Webhook
Triggered when a billing cycle is skipped.
File: app/routes/webhooks.subscription_billing_cycles.skip.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
import { jobs , CustomerSendEmailJob } from '~/jobs' ;
import { CustomerEmailTemplateName } from '~/services/CustomerSendEmailService' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Send skip confirmation email
const emailParams = {
shop ,
payload: {
... payload ,
emailTemplate: CustomerEmailTemplateName . BillingCycleSkipped ,
},
};
jobs . enqueue ( new CustomerSendEmailJob ( emailParams ));
return new Response ();
};
Selling Plan Group Update Webhook
Triggered when a selling plan group is created or updated.
File: app/routes/webhooks.selling_plan_groups.create_or_update.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received webhook' );
// Add custom logic here
// Examples:
// - Sync with external systems
// - Update analytics
// - Clear caches
return new Response ();
};
GDPR Compliance Webhooks
Customer Data Request / Redact
File: app/routes/webhooks.customer_redact.ts
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received GDPR webhook' );
// Implement customer data deletion
// 1. Identify all customer data in your database
// 2. Delete or anonymize the data
// 3. Log the action for compliance
return new Response ();
};
Shop Data Redact
File: app/routes/webhooks.shop_redact.ts
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'Received shop redact webhook' );
// Implement shop data deletion
// 1. Delete all shop-related data
// 2. Remove sessions
// 3. Clean up any stored credentials
// 4. Log the action for compliance
return new Response ();
};
GDPR webhooks are legally required. You have 30 days to comply with data deletion requests.
App Uninstalled Webhook
File: app/routes/webhooks.app.uninstalled.tsx
import type { ActionFunctionArgs } from '@remix-run/node' ;
import { authenticate } from '~/shopify.server' ;
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop , payload }, 'App uninstalled' );
// Clean up:
// - Remove sessions
// - Cancel scheduled jobs
// - Update app status
return new Response ();
};
Error Handling Best Practices
Retry Logic
Shopify will retry failed webhooks automatically:
First retry: after 1 second
Subsequent retries: exponential backoff up to 48 hours
Idempotency
Webhooks may be delivered more than once. Ensure your handlers are idempotent:
export const action = async ({ request } : ActionFunctionArgs ) => {
const { topic , shop , payload } = await authenticate . webhook ( request );
// Check if webhook was already processed
const webhookId = request . headers . get ( 'X-Shopify-Webhook-Id' );
const processed = await db . webhookLog . findUnique ({
where: { id: webhookId },
});
if ( processed ) {
logger . info ({ webhookId }, 'Webhook already processed' );
return new Response ();
}
// Process webhook
await processWebhook ( payload );
// Mark as processed
await db . webhookLog . create ({
data: {
id: webhookId ,
topic ,
shop ,
processedAt: new Date (),
},
});
return new Response ();
};
Error Responses
export const action = async ({ request } : ActionFunctionArgs ) => {
try {
const { topic , shop , payload } = await authenticate . webhook ( request );
await processWebhook ( payload );
return new Response ( null , { status: 200 });
} catch ( error ) {
logger . error ({ error }, 'Webhook processing failed' );
// Return 500 to trigger Shopify retry
return new Response ( null , { status: 500 });
}
};
Webhook Payload Types
Define TypeScript types for webhook payloads:
namespace Webhooks {
export interface SubscriptionContractsCreate {
admin_graphql_api_id : string ;
admin_graphql_api_customer_id : string ;
admin_graphql_api_origin_order_id : string | null ;
status : string ;
billing_policy : {
interval : string ;
interval_count : number ;
};
delivery_policy : {
interval : string ;
interval_count : number ;
};
}
export interface SubscriptionBillingAttemptSuccess {
admin_graphql_api_id : string ;
admin_graphql_api_order_id : string ;
admin_graphql_api_subscription_contract_id : string ;
idempotency_key : string ;
}
export interface SubscriptionBillingAttemptFailure {
admin_graphql_api_id : string ;
admin_graphql_api_subscription_contract_id : string ;
error_code : string ;
error_message : string ;
}
}
Testing Webhooks
Test webhooks locally using the Shopify CLI:
shopify webhook trigger --topic subscription_contracts/create
Or use the webhook testing tool in your Partner Dashboard.
Monitoring
Monitor webhook delivery in:
Partner Dashboard : View webhook logs and retry status
App logs : Use structured logging for debugging
Error tracking : Integrate Sentry or similar tools
import { logger } from '~/utils/logger.server' ;
export const action = async ({ request } : ActionFunctionArgs ) => {
const startTime = Date . now ();
const { topic , shop , payload } = await authenticate . webhook ( request );
logger . info ({ topic , shop }, 'Processing webhook' );
try {
await processWebhook ( payload );
const duration = Date . now () - startTime ;
logger . info ({ topic , shop , duration }, 'Webhook processed successfully' );
} catch ( error ) {
logger . error ({ topic , shop , error }, 'Webhook processing failed' );
throw error ;
}
return new Response ();
};
Next Steps
Testing Learn how to test webhook handlers
Managing Subscriptions Manage subscription lifecycle