Skip to main content
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

Build docs developers (and LLMs) love