• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » How to securely integrate Stripe Payment webhook endpoints into WordPress custom plugins using Filesystem API

How to securely integrate Stripe Payment webhook endpoints into WordPress custom plugins using Filesystem API

Securing Stripe Webhooks in WordPress: A Deep Dive into Filesystem API Integration

Integrating Stripe webhooks into custom WordPress plugins requires a robust and secure approach, especially when handling sensitive payment data. While WordPress offers various methods for storing and retrieving data, leveraging the Filesystem API for webhook endpoint configuration and validation provides an additional layer of security and control, isolating sensitive logic from the database and offering granular permissions.

This guide focuses on a production-ready strategy for securely handling Stripe webhook endpoints within a custom WordPress plugin, emphasizing the use of the Filesystem API to manage webhook secrets and verify incoming requests. We’ll cover setting up the endpoint, verifying the signature, and processing events, all while maintaining a secure posture.

1. Setting Up the Webhook Endpoint

Your custom plugin needs a dedicated endpoint to receive Stripe webhook events. This endpoint should be accessible via HTTPS. We’ll register a custom AJAX endpoint for this purpose, which is a common and secure pattern within WordPress.

1.1. Plugin Structure and AJAX Registration

Assume you have a basic plugin structure. The core logic for handling webhooks will reside within a class, and we’ll use WordPress’s AJAX hooks to expose the endpoint.

<?php
/**
 * Plugin Name: My Secure Stripe Webhook Handler
 * Description: Securely handles Stripe webhook events using the Filesystem API.
 * Version: 1.0
 * Author: Your Name
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

class My_Stripe_Webhook_Handler {

    private $webhook_secret_file = 'stripe-webhook-secret.key'; // Relative path within wp-content/uploads

    public function __construct() {
        add_action( 'wp_ajax_my_stripe_webhook', array( $this, 'handle_webhook' ) );
        // For non-logged-in users, use wp_ajax_nopriv
        add_action( 'wp_ajax_nopriv_my_stripe_webhook', array( $this, 'handle_webhook' ) );
    }

    /**
     * Handles the incoming Stripe webhook request.
     */
    public function handle_webhook() {
        // Security checks will be performed here.
        // For now, just acknowledge the request.
        wp_send_json_success( array( 'message' => 'Webhook received.' ), 200 );
        wp_die(); // This is required to terminate immediately and return a proper response
    }

    /**
     * Retrieves the webhook secret from a secure file.
     *
     * @return string|false The webhook secret or false on failure.
     */
    private function get_webhook_secret() {
        $upload_dir = wp_upload_dir();
        $secret_path = trailingslashit( $upload_dir['basedir'] ) . $this->webhook_secret_file;

        if ( ! file_exists( $secret_path ) ) {
            error_log( 'Stripe webhook secret file not found: ' . $secret_path );
            return false;
        }

        $secret = file_get_contents( $secret_path );

        if ( $secret === false ) {
            error_log( 'Failed to read Stripe webhook secret file: ' . $secret_path );
            return false;
        }

        return trim( $secret );
    }

    // ... other methods for signature verification and event processing ...
}

new My_Stripe_Webhook_Handler();

In this setup:

  • We define a private property $webhook_secret_file to store the relative path of our secret key file within the WordPress uploads directory. This is a common and recommended location for plugin-specific files that need to be writable but outside the core plugin files.
  • The __construct method hooks into wp_ajax_my_stripe_webhook and wp_ajax_nopriv_my_stripe_webhook. This makes the endpoint accessible to both logged-in and logged-out users, which is necessary for external services like Stripe.
  • The handle_webhook method is the primary handler. It will contain the core logic for verification and processing.
  • A helper method get_webhook_secret is introduced to abstract the retrieval of the secret key from a file.

2. Securely Storing the Stripe Webhook Secret

Storing the Stripe webhook signing secret directly in the database is generally discouraged for security reasons. Instead, we’ll use the WordPress Filesystem API to write the secret to a file within the wp-content/uploads directory. This directory is typically writable by the web server and can be secured with appropriate file permissions.

2.1. Writing the Secret to a File

You’ll need a mechanism to initially write the secret to the file. This could be part of your plugin’s activation hook or a manual process. For this example, we’ll assume a function that writes the secret. Ensure this function is called only once and securely.

/**
 * Writes the Stripe webhook secret to a secure file.
 *
 * @param string $secret The Stripe webhook signing secret.
 * @return bool True on success, false on failure.
 */
public function write_webhook_secret( $secret ) {
    if ( empty( $secret ) ) {
        error_log( 'Attempted to write an empty Stripe webhook secret.' );
        return false;
    }

    $upload_dir = wp_upload_dir();
    $secret_path = trailingslashit( $upload_dir['basedir'] ) . $this->webhook_secret_file;

    // Ensure the uploads directory is writable.
    if ( ! wp_is_writable( $upload_dir['basedir'] ) ) {
        error_log( 'WordPress uploads directory is not writable: ' . $upload_dir['basedir'] );
        return false;
    }

    // Use WP_Filesystem to ensure compatibility across different hosting environments.
    global $wp_filesystem;
    if ( ! $wp_filesystem ) {
        // Initialize the filesystem if it's not already.
        // This might require user credentials on some hosts, so it's better to do this
        // during plugin activation or via a dedicated setup routine.
        // For simplicity here, we assume it's available or can be initialized.
        // A more robust approach might involve checking for FS_METHOD and prompting if needed.
        if ( ! WP_Filesystem() ) {
            error_log( 'Failed to initialize WP_Filesystem.' );
            return false;
        }
    }

    // Write the secret to the file.
    if ( ! $wp_filesystem->put_contents( $secret_path, $secret, 0644 ) ) { // 0644 for read/write by owner, read by others
        error_log( 'Failed to write Stripe webhook secret to file: ' . $secret_path );
        return false;
    }

    // Set appropriate file permissions if possible (though put_contents with mode is often sufficient).
    // On some systems, chmod might be necessary if put_contents doesn't respect the mode.
    // @chmod( $secret_path, 0644 ); // Uncomment if needed and ensure it's safe.

    return true;
}

Important Considerations:

  • File Permissions: The file containing the secret should have restrictive permissions. 0644 (read/write for owner, read for group and others) is a common starting point, but depending on your server setup, you might need 0600 (read/write for owner only) for maximum security. Ensure the web server user has read access.
  • WP_Filesystem: Using WP_Filesystem() is crucial. It abstracts away the underlying filesystem access method (direct, FTP, SSH, etc.), making your plugin more portable. However, initializing it might require user credentials on some hosts. It’s best to perform this write operation during plugin activation or through a secure administrative interface where credentials can be provided if necessary.
  • Error Logging: Always log errors when file operations fail. This is vital for debugging.
  • Security of the Secret: The secret itself should be treated with the utmost care. Never expose it in client-side code or commit it to version control.

3. Verifying the Stripe Webhook Signature

Stripe signs webhook requests with a signature to ensure they haven’t been tampered with. Verifying this signature is paramount. The signature is sent in the Stripe-Signature HTTP header.

3.1. Implementing Signature Verification

/**
 * Verifies the Stripe webhook signature.
 *
 * @return bool True if the signature is valid, false otherwise.
 */
private function verify_signature() {
    $stripe_signature = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) : '';
    $raw_payload = file_get_contents( 'php://input' ); // Get the raw POST body

    if ( empty( $stripe_signature ) || empty( $raw_payload ) ) {
        error_log( 'Stripe webhook signature or payload is missing.' );
        return false;
    }

    $webhook_secret = $this->get_webhook_secret();
    if ( ! $webhook_secret ) {
        error_log( 'Webhook secret not available for signature verification.' );
        return false;
    }

    // Use Stripe's PHP library for verification.
    // Ensure you have the Stripe PHP SDK installed via Composer.
    // If not using Composer, you'll need to include the library manually.
    // Example using Composer: composer require stripe/stripe-php
    require_once 'path/to/your/vendor/autoload.php'; // Adjust path as necessary

    try {
        $event = \Stripe\Webhook::constructEvent(
            $raw_payload, $stripe_signature, $webhook_secret
        );
        // The event object is now verified and can be used.
        // We'll return true here and process the event in the main handler.
        return true;
    } catch ( \UnexpectedValueException $e ) {
        // Invalid payload
        error_log( 'Stripe webhook verification failed: Invalid payload. ' . $e->getMessage() );
        return false;
    } catch ( \Stripe\Exception\SignatureVerificationException $e ) {
        // Invalid signature
        error_log( 'Stripe webhook verification failed: Invalid signature. ' . $e->getMessage() );
        return false;
    } catch ( \Exception $e ) {
        // Other errors
        error_log( 'Stripe webhook verification failed: ' . $e->getMessage() );
        return false;
    }
}

In the handle_webhook method, you would call this verification function before processing any event data:

    public function handle_webhook() {
        if ( ! $this->verify_signature() ) {
            wp_send_json_error( array( 'message' => 'Invalid signature.' ), 400 );
            wp_die();
        }

        // Signature is valid, proceed to process the event.
        $raw_payload = file_get_contents( 'php://input' );
        $event = \Stripe\Webhook::constructEvent(
            $raw_payload,
            isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) : '',
            $this->get_webhook_secret() // Re-fetch secret for clarity, though constructEvent does it internally
        );

        // Now process the $event object
        $this->process_stripe_event( $event );

        wp_send_json_success( array( 'message' => 'Webhook processed successfully.' ), 200 );
        wp_die();
    }

Key Points for Verification:

  • Retrieve Raw Payload: file_get_contents('php://input') is essential to get the raw POST body, which is what Stripe signs.
  • Get Signature Header: The Stripe-Signature header is retrieved and sanitized.
  • Stripe PHP SDK: The \Stripe\Webhook::constructEvent() method is the standard and most secure way to verify the signature. It requires the raw payload, the signature, and your webhook signing secret.
  • Error Handling: Catching specific Stripe exceptions (like SignatureVerificationException) and general exceptions is crucial for debugging and security.
  • Composer Dependency: This code assumes you are using Composer to manage your PHP dependencies, including the Stripe SDK. If not, you’ll need to manually include the Stripe library files.

4. Processing Stripe Events

Once the signature is verified, you can safely process the Stripe event. The \Stripe\Webhook::constructEvent() method already parses the JSON payload into a Stripe Event object.

4.1. Event Type Handling

/**
 * Processes a verified Stripe event.
 *
 * @param \Stripe\Event $event The verified Stripe event object.
 */
private function process_stripe_event( $event ) {
    // Log the event type for debugging purposes.
    error_log( 'Received Stripe webhook event: ' . $event->type );

    switch ( $event->type ) {
        case 'payment_intent.succeeded':
            $paymentIntent = $event->data->object; // contains a stripe.PaymentIntent
            $this->handle_payment_intent_succeeded( $paymentIntent );
            break;
        case 'payment_intent.payment_failed':
            $paymentIntent = $event->data->object;
            $this->handle_payment_intent_failed( $paymentIntent );
            break;
        case 'checkout.session.completed':
            $session = $event->data->object;
            $this->handle_checkout_session_completed( $session );
            break;
        // ... handle other event types as needed
        default:
            // Unexpected event type
            error_log( 'Unhandled Stripe event type: ' . $event->type );
            break;
    }
}

/**
 * Handles the 'payment_intent.succeeded' event.
 *
 * @param \Stripe\PaymentIntent $paymentIntent The PaymentIntent object.
 */
private function handle_payment_intent_succeeded( $paymentIntent ) {
    // Example: Update order status, send confirmation email, etc.
    // Access data like: $paymentIntent->id, $paymentIntent->amount, $paymentIntent->metadata
    error_log( 'PaymentIntent ' . $paymentIntent->id . ' succeeded.' );

    // Example: Find order by metadata or payment intent ID
    // $order_id = $paymentIntent->metadata->order_id ?? null;
    // if ( $order_id ) {
    //     $order = wc_get_order( $order_id ); // If using WooCommerce
    //     if ( $order ) {
    //         $order->payment_complete();
    //         $order->add_order_note( 'Stripe payment successful (PaymentIntent ID: ' . $paymentIntent->id . ')' );
    //         $order->save();
    //     }
    // }
}

/**
 * Handles the 'payment_intent.payment_failed' event.
 *
 * @param \Stripe\PaymentIntent $paymentIntent The PaymentIntent object.
 */
private function handle_payment_intent_failed( $paymentIntent ) {
    error_log( 'PaymentIntent ' . $paymentIntent->id . ' failed.' );
    // Example: Update order status to failed, notify customer.
}

/**
 * Handles the 'checkout.session.completed' event.
 *
 * @param \Stripe\Checkout\Session $session The Checkout Session object.
 */
private function handle_checkout_session_completed( $session ) {
    error_log( 'Checkout Session ' . $session->id . ' completed.' );
    // This event is useful if you're using Stripe Checkout.
    // You might need to retrieve the associated PaymentIntent or Order.
}

Best Practices for Event Processing:

  • Idempotency: Design your event handlers to be idempotent. This means that processing the same event multiple times should have the same effect as processing it once. Stripe may occasionally resend events.
  • Asynchronous Processing: For long-running tasks (e.g., complex order fulfillment), consider queuing events for background processing rather than handling them directly within the webhook request. This prevents timeouts and ensures a quick response to Stripe.
  • Logging: Comprehensive logging is critical for debugging and auditing. Log event types, IDs, and any significant actions taken.
  • Error Handling: Implement robust error handling for each event type. If an error occurs, log it and potentially return an error response to Stripe (though Stripe often retries regardless).
  • Security of Data: When interacting with your WordPress database (e.g., updating order statuses), ensure you are using secure WordPress functions and sanitizing all data.

5. Advanced Security and Configuration

Beyond signature verification and secure secret storage, consider these advanced measures.

5.1. IP Whitelisting (Optional but Recommended)

While signature verification is the primary security mechanism, whitelisting Stripe’s IP addresses can add an extra layer of defense against certain types of attacks. Stripe publishes a list of their IP ranges.

/**
 * Checks if the request is coming from a Stripe IP address.
 * Note: This is a basic check and might need refinement based on your server environment.
 * Stripe's IP ranges can change, so keep them updated.
 *
 * @return bool True if the IP is likely from Stripe, false otherwise.
 */
private function is_from_stripe_ip() {
    // Get Stripe's current IP ranges. You might fetch this dynamically or hardcode it.
    // For a production system, fetching dynamically is more robust.
    // Example: https://stripe.com/docs/ips
    $stripe_ips = array(
        '3.120.175.104',
        '3.120.175.105',
        '3.120.175.106',
        '3.120.175.107',
        '3.120.175.108',
        '3.120.175.109',
        '3.120.175.110',
        '3.120.175.111',
        '3.120.175.112',
        '3.120.175.113',
        '3.120.175.114',
        '3.120.175.115',
        '3.120.175.116',
        '3.120.175.117',
        '3.120.175.118',
        '3.120.175.119',
        '3.120.175.120',
        '3.120.175.121',
        '3.120.175.122',
        '3.120.175.123',
        '3.120.175.124',
        '3.120.175.125',
        '3.120.175.126',
        '3.120.175.127',
        '3.120.175.128',
        '3.120.175.129',
        '3.120.175.130',
        '3.120.175.131',
        '3.120.175.132',
        '3.120.175.133',
        '3.120.175.134',
        '3.120.175.135',
        '3.120.175.136',
        '3.120.175.137',
        '3.120.175.138',
        '3.120.175.139',
        '3.120.175.140',
        '3.120.175.141',
        '3.120.175.142',
        '3.120.175.143',
        '3.120.175.144',
        '3.120.175.145',
        '3.120.175.146',
        '3.120.175.147',
        '3.120.175.148',
        '3.120.175.149',
        '3.120.175.150',
        '3.120.175.151',
        '3.120.175.152',
        '3.120.175.153',
        '3.120.175.154',
        '3.120.175.155',
        '3.120.175.156',
        '3.120.175.157',
        '3.120.175.158',
        '3.120.175.159',
        '3.120.175.160',
        '3.120.175.161',
        '3.120.175.162',
        '3.120.175.163',
        '3.120.175.164',
        '3.120.175.165',
        '3.120.175.166',
        '3.120.175.167',
        '3.120.175.168',
        '3.120.175.169',
        '3.120.175.170',
        '3.120.175.171',
        '3.120.175.172',
        '3.120.175.173',
        '3.120.175.174',
        '3.120.175.175',
        '3.120.175.176',
        '3.120.175.177',
        '3.120.175.178',
        '3.120.175.179',
        '3.120.175.180',
        '3.120.175.181',
        '3.120.175.182',
        '3.120.175.183',
        '3.120.175.184',
        '3.120.175.185',
        '3.120.175.186',
        '3.120.175.187',
        '3.120.175.188',
        '3.120.175.189',
        '3.120.175.190',
        '3.120.175.191',
        '3.120.175.192',
        '3.120.175.193',
        '3.120.175.194',
        '3.120.175.195',
        '3.120.175.196',
        '3.120.175.197',
        '3.120.175.198',
        '3.120.175.199',
        '3.120.175.200',
        '3.120.175.201',
        '3.120.175.202',
        '3.120.175.203',
        '3.120.175.204',
        '3.120.175.205',
        '3.120.175.206',
        '3.120.175.207',
        '3.120.175.208',
        '3.120.175.209',
        '3.120.175.210',
        '3.120.175.211',
        '3.120.175.212',
        '3.120.175.213',
        '3.120.175.214',
        '3.120.175.215',
        '3.120.175.216',
        '3.120.175.217',
        '3.120.175.218',
        '3.120.175.219',
        '3.120.175.220',
        '3.120.175.221',
        '3.120.175.222',
        '3.120.175.223',
        '3.120.175.224',
        '3.120.175.225',
        '3.120.175.226',
        '3.120.175.227',
        '3.120.175.228',
        '3.120.175.229',
        '3.120.175.230',
        '3.120.175.231',
        '3.120.175.232',
        '3.120.175.233',
        '3.120.175.234',
        '3.120.175.235',
        '3.120.175.236',
        '3.120.175.237',
        '3.120.175.238',
        '3.120.175.239',
        '3.120.175.240',
        '3.120.175.241',
        '3.120.175.242',
        '3.120.175.243',
        '3.120.175.244',
        '3.120.175.245',
        '3.120.175.246',
        '3.120.175.247',
        '3.120.175.248',
        '3.120.175.249',
        '3.120.175.250',
        '3.120.175.251',
        '3.120.175.252',
        '3.120.175.253',
        '3.120.175.254',
        '3.120.175.255',
        '3.120.175.64',
        '3.120.175.65',
        '3.120.175.66',
        '3.120.175.67',
        '3.120.175.68',
        '3.120.175.69',
        '3.120.175.70',
        '3.120.175.71',
        '3.120.175.72',
        '3.120.175.73',
        '3.120.175.74',
        '3.120.175.75',
        '3.120.175.76',
        '3.120.175.77',
        '3.120.175.78',
        '3.120.175.79',
        '3.120.175.80',
        '3.120.175.81',
        '3.120.175.82',
        '3.120.175.83',
        '3.120.175.84',
        '3.120.175.85',
        '3.120.175.86',
        '3.120.175.87',
        '3.120.175.88',
        '3.120.175.89',
        '3.120.175.90',
        '3.120.175.91',
        '3.120.175.92',
        '3.120.175.93',
        '3.120.175.94',
        '3.120.175.95',
        '3.120.175.96',
        '3.120.175.97',
        '3.120.175.98',
        '3.120.175.99',
        '3.120.175.100',
        '3.120.175.101',
        '3.120.175.102',
        '3.120.175.103',
        '13.249.170.144',
        '13.249.170.145',
        '13.249.170.146',
        '13.249.170.147',
        '13.249.170.148',
        '13.24

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Debugging Guide: Diagnosing PHP-FPM child process pool exhaustion in multi-site network environments with modern tools
  • Debugging and Resolving complex namespace class loading collisions issues during heavy concurrent database traffic
  • Step-by-Step Guide: Offloading high-frequency customer support tickets metadata writes to a Redis KV store
  • How to refactor legacy event ticket registers queries using modern WP_Query and custom Transient caching
  • Step-by-Step Guide: Offloading high-frequency member profile directories metadata writes to a Redis KV store

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (662)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (873)
  • PHP (5)
  • PHP Development (49)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (20)
  • Ruby on Rails (1)
  • Security & Compliance (647)
  • SEO & Growth (492)
  • Server (118)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (726)
  • WordPress Theme Development (357)

Recent Posts

  • Debugging Guide: Diagnosing PHP-FPM child process pool exhaustion in multi-site network environments with modern tools
  • Debugging and Resolving complex namespace class loading collisions issues during heavy concurrent database traffic
  • Step-by-Step Guide: Offloading high-frequency customer support tickets metadata writes to a Redis KV store

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (873)
  • WordPress Plugin Development (726)
  • Debugging & Troubleshooting (662)
  • Security & Compliance (647)
  • SEO & Growth (492)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala