Last updated

Webhooks Guide

Welcome to the comprehensive guide for using webhooks in the Fluid platform. Webhooks provide a powerful way to receive real-time notifications when events occur in your Fluid commerce environment, enabling you to build responsive integrations and automate business processes.

Introduction to Webhooks in Fluid

What are Webhooks?

Webhooks are HTTP callbacks that Fluid sends to your application when specific events occur. Instead of continuously polling our API for changes, webhooks allow your system to receive instant notifications, making your integrations more efficient and responsive.

Why Use Webhooks?

  • Real-time Updates: Receive instant notifications when events occur
  • Reduced API Calls: Eliminate the need for constant polling
  • Automated Workflows: Trigger business processes automatically
  • Better User Experience: Provide immediate feedback to your customers
  • Resource Efficiency: Lower server load and improved performance

Common Use Cases

  • Order Processing: Automatically fulfill orders, send confirmation emails, or update inventory
  • Customer Management: Sync customer data with CRM systems or trigger marketing campaigns
  • Inventory Tracking: Update stock levels across multiple sales channels
  • Analytics: Feed real-time data into business intelligence tools
  • Notifications: Send SMS, email, or push notifications to customers and staff

How Webhooks Work in Fluid

Event SourceFluid PlatformWebhook EndpointYour ApplicationEvent occurs (e.g., order created)Process eventHTTP POST with payloadProcess webhook dataHTTP 200 responseConfirm receiptEvent SourceFluid PlatformWebhook EndpointYour Application

When an event occurs in your Fluid environment (such as a new order being created), the platform automatically sends an HTTP request to your configured webhook endpoint with relevant data about the event.

Creating and Configuring Webhooks

Setting Up Webhooks via API

Webhooks are managed through the Fluid Company API. Here's how to create and configure them:

Creating a Webhook

curl -X POST "https://api.fluid.app/api/company/webhooks" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resource": "order",
    "event": "created",
    "url": "https://your-app.com/webhooks/orders"
  }'

Required Parameters

  • resource: The type of resource to monitor (e.g., "order", "cart", "customer")
  • event: The specific event to listen for (e.g., "created", "updated", "completed")
  • url: Your endpoint URL that will receive the webhook calls

Optional Parameters

  • http_method: HTTP method to use (default: "post")
  • auth_token: Custom authentication token for webhook verification
  • synchronous: Whether to call webhook synchronously (default: false)

Response

{
  "webhook": {
    "id": 123,
    "company_id": 1,
    "resource": "order",
    "event": "created",
    "event_identifier": "order_created",
    "url": "https://your-app.com/webhooks/orders",
    "http_method": "post",
    "active": true,
    "auth_token": "abc123def456",
    "created_at": "2024-01-01T00:00:00Z",
    "updated_at": "2024-01-01T00:00:00Z"
  }
}

Managing Existing Webhooks

List All Webhooks

curl -X GET "https://api.fluid.app/api/company/webhooks" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN"

Update a Webhook

curl -X PUT "https://api.fluid.app/api/company/webhooks/123" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "url": "https://your-app.com/new-webhook-endpoint",
      "auth_token": "new-auth-token"
    }
  }'

Delete a Webhook

curl -X DELETE "https://api.fluid.app/api/company/webhooks/123" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN"

Configuration Best Practices

  • Use HTTPS: Always use secure HTTPS endpoints for webhook URLs
  • Validate URLs: Ensure your webhook endpoints are accessible and return proper HTTP status codes
  • Set Timeouts: Configure appropriate timeout values for your webhook endpoints
  • Handle Failures: Implement proper error handling and retry logic

Available Webhook Events

Fluid supports webhooks for multiple resource types. Each resource has specific events that can trigger webhook calls.

Complete Event Reference

Webhook Events
Commerce Events
Customer Events
System Events
Cart Events
Order Events
Product Events
Subscription Events
cart_updated
cart_abandoned
cart_update_address
cart_update_cart_email
cart_add_items
cart_remove_items
order_created
order_updated
order_completed
order_shipped
order_cancelled
order_refunded
product_created
product_updated
product_destroyed
subscription_started
subscription_paused
subscription_cancelled
customer_created
customer_updated

Commerce Events

Cart Events

  • cart_updated: Triggered when cart contents or details are modified. Uses CartBlueprinter with :for_webhook_payload view
  • cart_abandoned: Fired when a cart is considered abandoned based on company settings
  • cart_update_address: Called when shipping or billing address is updated
  • cart_update_cart_email: Triggered when the cart email is changed
  • cart_add_items: Fired when items are added to the cart
  • cart_remove_items: Called when items are removed from the cart

Order Events

  • order_created: Triggered when a new order is placed. Uses OrderBlueprinter with :for_webhook_payload view
  • order_updated: Fired when order details are modified. Uses OrderBlueprinter with :for_webhook_payload view
  • order_completed: Called when an order is marked as delivered/completed
  • order_shipped: Triggered when an order is marked as shipped
  • order_cancelled: Fired when an order is cancelled
  • order_refunded: Called when a refund is processed for an order

Product Events

  • product_created: Triggered when a new product is added. Uses default Product serialization
  • product_updated: Fired when product details are modified. Uses default Product serialization
  • product_destroyed: Called when a product is deleted. Uses default Product serialization

Subscription Events

  • subscription_started: Triggered when a new subscription begins. Uses OrderSerializer with custom_key "subscription_order"
  • subscription_paused: Fired when a subscription is paused. Uses OrderSerializer with custom_key "subscription_order"
  • subscription_cancelled: Called when a subscription is cancelled. Uses OrderSerializer with custom_key "subscription_order"

Customer & Contact Events

  • customer_created: New customer account created
  • customer_updated: Customer information modified
  • contact_created: New contact/lead added
  • contact_updated: Contact information updated

System Events

  • user_created: New user account created
  • user_updated: User information modified
  • user_deactivated: User account deactivated
  • enrollment_completed: User enrollment process finished
  • event_created: Custom event created
  • event_updated: Custom event modified
  • event_deleted: Custom event removed

Integration Events

  • droplet_installed: Droplet/app installed
  • droplet_uninstalled: Droplet/app removed
  • bot_message_created: Bot message sent
  • popup_submitted: Popup form submitted
  • webchat_submitted: Web chat form submitted

Event Naming Convention

All webhook events follow the pattern: {resource}_{event}

  • Resource names are singular (e.g., "order", not "orders")
  • Event names use past tense (e.g., "created", not "create")
  • Multiple words are separated by underscores

Sample Payloads

Order Event Payloads

When order events are triggered, Fluid sends detailed information about the order. Here are examples of the JSON payloads you'll receive:

Order Created/Updated Payload

{
  "id": "whe_1234567890abcdef",
  "identifier": "whe_unique_identifier",
  "name": "order_created",
  "payload": {
    "event_name": "order_created",
    "company_id": 1,
    "resource_name": "Commerce::Order",
    "resource": "order",
    "event": "created",
    "order": {
      "subtotal": 99.99,
      "tax": 8.50,
      "discount": 10.00,
      "shipping": 5.99,
      "external_id": "ext_order_123",
      "metadata": {
        "source": "web",
        "campaign": "summer_sale"
      },
      "order_class": "customer_order",
      "rep": {
        "id": 456,
        "external_id": "rep_789"
      },
      "items": [
        {
          "id": 1,
          "product_id": 100,
          "variant_id": 200,
          "quantity": 2,
          "price": 49.99,
          "total": 99.98,
          "sku": "PROD-001",
          "title": "Premium Widget",
          "variant_title": "Blue - Large"
        }
      ],
      "ship_to": {
        "id": 301,
        "first_name": "John",
        "last_name": "Doe",
        "address1": "123 Main St",
        "address2": "Apt 4B",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "US",
        "phone": "+1-555-123-4567"
      },
      "bill_to": {
        "id": 302,
        "first_name": "John",
        "last_name": "Doe",
        "address1": "123 Main St",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "US"
      }
    }
  },
  "timestamp": "2024-01-01T12:00:00Z"
}

Order Status Change Payloads

When an order status changes, specific event identifiers are used:

Order Shipped (order_shipped):

{
  "payload": {
    "event_name": "order_shipped",
    "order": {
      "id": 12345,
      "status": "shipped",
      "tracking_number": "1Z999AA1234567890",
      "shipped_at": "2024-01-02T10:30:00Z"
    }
  }
}

Order Completed (order_completed):

{
  "payload": {
    "event_name": "order_completed",
    "order": {
      "id": 12345,
      "status": "delivered",
      "delivered_at": "2024-01-05T14:20:00Z"
    }
  }
}

Order Cancelled (order_cancelled):

{
  "payload": {
    "event_name": "order_cancelled",
    "order": {
      "id": 12345,
      "status": "cancelled",
      "cancelled_at": "2024-01-01T15:45:00Z",
      "cancellation_reason": "Customer request"
    }
  }
}

Cart Event Payloads

Cart webhooks provide information about shopping cart changes based on the CartBlueprinter :for_webhook_payload view:

Cart Updated Payload

{
  "id": "whe_1234567890abcdef",
  "identifier": "whe_unique_identifier",
  "name": "cart_updated",
  "payload": {
    "event_name": "cart_updated",
    "company_id": 1,
    "resource_name": "Commerce::Cart",
    "resource": "cart",
    "event": "updated",
    "data": {
      "id": 67890,
      "state": "start",
      "payment_method": null,
      "amount_total": 170.72,
      "sub_total": 149.98,
      "tax_total": 12.75,
      "shipping_total": 7.99,
      "discount_total": 0.00,
      "currency_code": "USD",
      "email": "customer@example.com",
      "first_name": "John",
      "last_name": "Doe",
      "phone": "+1-555-123-4567",
      "items": [
        {
          "id": 1,
          "title": "Premium Widget",
          "quantity": 2,
          "price": 74.99,
          "total": 149.98,
          "sku": "WIDGET-001",
          "tax": 6.38,
          "cv": 50.0,
          "qv": 40.0,
          "product": {
            "id": 100,
            "title": "Premium Widget",
            "sku": "WIDGET-001",
            "price": 74.99
          },
          "variant": {
            "id": 200,
            "title": "Blue - Large",
            "sku": "WIDGET-001-BL"
          }
        }
      ],
      "ship_to": {
        "id": 301,
        "first_name": "John",
        "last_name": "Doe",
        "address1": "123 Main St",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "US"
      },
      "bill_to": {
        "id": 302,
        "first_name": "John",
        "last_name": "Doe",
        "address1": "123 Main St",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "US"
      }
    }
  },
  "timestamp": "2024-01-01T11:30:00Z"
}

Cart Abandoned Payload

{
  "payload": {
    "event_name": "cart_abandoned",
    "data": {
      "id": 67890,
      "email": "customer@example.com",
      "amount_total": 170.72,
      "items_count": 2,
      "abandoned_at": "2024-01-01T11:30:00Z"
    }
  }
}

Cart Add Items Payload

{
  "payload": {
    "event_name": "cart_add_items",
    "data": {
      "id": 67890,
      "items_added": [
        {
          "id": 2,
          "title": "New Product",
          "quantity": 1,
          "price": 25.00,
          "sku": "NEW-001"
        }
      ],
      "amount_total": 195.72,
      "items_count": 3
    }
  }
}

Cart Remove Items Payload

{
  "payload": {
    "event_name": "cart_remove_items",
    "data": {
      "id": 67890,
      "items_removed": [
        {
          "id": 1,
          "title": "Removed Product",
          "quantity": 1,
          "sku": "REM-001"
        }
      ],
      "amount_total": 145.72,
      "items_count": 2
    }
  }
}

Customer Event Payloads

Customer webhooks include comprehensive customer profile information based on the CustomerBlueprinter:

Customer Created Payload

{
  "id": "whe_1234567890abcdef",
  "identifier": "whe_unique_identifier",
  "name": "customer_created",
  "payload": {
    "event_name": "customer_created",
    "company_id": 1,
    "resource_name": "Customer",
    "resource": "customer",
    "event": "created",
    "data": {
      "id": 789,
      "email": "newcustomer@example.com",
      "first_name": "Jane",
      "last_name": "Smith",
      "phone": "+1-555-987-6543",
      "orders_count": 0,
      "total_spent": 0.0,
      "subscription_count": 0,
      "is_rep": false,
      "created_at": "2024-01-01T09:15:00Z",
      "updated_at": "2024-01-01T09:15:00Z",
      "addresses": [
        {
          "id": 401,
          "first_name": "Jane",
          "last_name": "Smith",
          "address1": "456 Oak Ave",
          "city": "Los Angeles",
          "state": "CA",
          "zip": "90210",
          "country": "US",
          "phone": "+1-555-987-6543"
        }
      ],
      "customer_notes": [],
      "payment_methods": []
    }
  },
  "timestamp": "2024-01-01T09:15:00Z"
}

Customer Updated Payload

{
  "payload": {
    "event_name": "customer_updated",
    "data": {
      "id": 789,
      "email": "jane.smith.updated@example.com",
      "first_name": "Jane",
      "last_name": "Smith-Johnson",
      "phone": "+1-555-987-6543",
      "orders_count": 3,
      "total_spent": 299.97,
      "subscription_count": 1,
      "updated_at": "2024-01-02T14:30:00Z"
    }
  }
}

Subscription Event Payloads

Subscription webhooks provide information about subscription lifecycle events using OrderSerializer with custom_key "subscription_order":

Subscription Started Payload

{
  "id": "whe_1234567890abcdef",
  "identifier": "whe_unique_identifier",
  "name": "subscription_started",
  "payload": {
    "event_name": "subscription_started",
    "company_id": 1,
    "resource_name": "SubscriptionOrder",
    "resource": "subscription",
    "event": "started",
    "subscription_order": {
      "id": 12345,
      "status": "active",
      "next_bill_date": "2024-02-01T00:00:00Z",
      "last_bill_date": null,
      "next_ship_date": "2024-02-01T00:00:00Z",
      "last_ship_date": null,
      "quantity": 1,
      "price": 29.99,
      "original_price": 34.99,
      "attempts": 0,
      "skipped_count": 0,
      "max_skips": 3,
      "trial_ends_at": "2024-01-15T00:00:00Z",
      "in_trial": true,
      "disabled": false,
      "notes": "Premium subscription for customer",
      "created_at": "2024-01-01T12:00:00Z",
      "updated_at": "2024-01-01T12:00:00Z",
      "subscription_plan": {
        "id": 10,
        "name": "Monthly Premium",
        "billing_interval": 1,
        "billing_interval_unit": "month",
        "price_adjustment_amount": 5.00,
        "price_adjustment_type": "discount"
      },
      "customer": {
        "id": 789,
        "email": "customer@example.com",
        "first_name": "John",
        "last_name": "Doe"
      },
      "variant": {
        "id": 200,
        "title": "Premium Subscription",
        "sku": "SUB-PREM-001"
      },
      "address": {
        "id": 301,
        "first_name": "John",
        "last_name": "Doe",
        "address1": "123 Main St",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "US"
      }
    }
  },
  "timestamp": "2024-01-01T12:00:00Z"
}

Subscription Paused Payload

{
  "payload": {
    "event_name": "subscription_paused",
    "subscription_order": {
      "id": 12345,
      "status": "paused",
      "next_bill_date": "2024-03-01T00:00:00Z",
      "last_bill_date": "2024-01-01T00:00:00Z",
      "paused_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z"
    }
  }
}

Subscription Cancelled Payload

{
  "payload": {
    "event_name": "subscription_cancelled",
    "subscription_order": {
      "id": 12345,
      "status": "cancelled",
      "cancelled_at": "2024-01-20T16:45:00Z",
      "updated_at": "2024-01-20T16:45:00Z",
      "cancellation_reason": "customer_request"
    }
  }
}

Product Event Payloads

Product webhooks provide information about product lifecycle events using default Product serialization:

Product Created Payload

{
  "id": "whe_1234567890abcdef",
  "identifier": "whe_unique_identifier",
  "name": "product_created",
  "payload": {
    "event_name": "product_created",
    "company_id": 1,
    "resource_name": "Product",
    "resource": "product",
    "event": "created",
    "data": {
      "id": 100,
      "title": "Premium Widget",
      "description": "High-quality widget for premium customers",
      "introduction": "Introducing our latest premium widget",
      "feature_text": "Advanced features for professional use",
      "slug": "premium-widget",
      "status": "active",
      "sku": "WIDGET-001",
      "upc": "123456789012",
      "hs_code": "8543709099",
      "image_url": "https://cdn.fluid.app/products/widget.jpg",
      "compressed_image_url": "https://cdn.fluid.app/products/widget_compressed.jpg",
      "image_path": "/products/widget.jpg",
      "external_url": "https://example.com/widget",
      "tax_category_id": 1,
      "public": true,
      "in_stock": true,
      "keep_selling": true,
      "track_quantity": true,
      "no_index": false,
      "commission": 10.0,
      "affiliate_commission": 5.0,
      "integration_id": "shopify_123",
      "external_id": "ext_prod_123",
      "last_synced_at": "2024-01-01T10:00:00Z",
      "metadata": {
        "source": "manual",
        "category": "widgets"
      },
      "publish_to_retail_store": true,
      "publish_to_rep_store": true,
      "publish_to_share_tab": true,
      "show_reviews": true,
      "international_tax_type": "standard",
      "created_at": "2024-01-01T10:00:00Z",
      "updated_at": "2024-01-01T10:00:00Z",
      "category": {
        "id": 5,
        "title": "Widgets",
        "description": "Premium widget collection",
        "image_url": "https://cdn.fluid.app/categories/widgets.jpg",
        "position": 1,
        "public": true
      },
      "collections": [
        {
          "id": 10,
          "title": "Premium Collection",
          "description": "Our premium product line",
          "position": 1,
          "public": true
        }
      ],
      "tags": [
        {
          "id": 1,
          "name": "premium"
        },
        {
          "id": 2,
          "name": "widget"
        }
      ],
      "label": {
        "id": 1,
        "title": "New",
        "background": "#ff0000",
        "icon": "star",
        "color": "#ffffff"
      },
      "images": [
        {
          "id": 1,
          "image_url": "https://cdn.fluid.app/products/widget_1.jpg",
          "image_path": "/products/widget_1.jpg",
          "position": 1
        }
      ],
      "variants": [
        {
          "id": 200,
          "title": "Blue - Large",
          "sku": "WIDGET-001-BL",
          "price": 74.99,
          "position": 1,
          "is_master": true
        }
      ]
    }
  },
  "timestamp": "2024-01-01T10:00:00Z"
}

Product Updated Payload

{
  "payload": {
    "event_name": "product_updated",
    "data": {
      "id": 100,
      "title": "Premium Widget - Updated",
      "description": "Updated high-quality widget for premium customers",
      "price": 79.99,
      "status": "active",
      "updated_at": "2024-01-02T15:30:00Z"
    }
  }
}

Product Destroyed Payload

{
  "payload": {
    "event_name": "product_destroyed",
    "data": {
      "id": 100,
      "title": "Premium Widget",
      "sku": "WIDGET-001",
      "status": "discarded",
      "destroyed_at": "2024-01-03T09:15:00Z"
    }
  }
}

Understanding Payload Structure

All webhook payloads follow a consistent structure:

  • id: Unique webhook event ID
  • identifier: Unique event identifier for tracking
  • name: Event name (matches event_identifier)
  • payload: Contains the actual event data
    • event_name: The specific event that occurred
    • company_id: Your company ID
    • resource_name: Full class name of the resource
    • resource: Resource type (order, cart, customer, etc.)
    • event: Event type (created, updated, etc.)
    • [resource]: The actual resource data
  • timestamp: ISO 8601 timestamp when the event occurred

Authentication and Security

Webhook Authentication

Fluid uses token-based authentication to secure webhook calls. Every webhook request includes authentication headers that you should verify to ensure the request is legitimate.

Authentication Headers

Each webhook request includes the following headers:

POST /your-webhook-endpoint HTTP/1.1
Host: your-app.com
Content-Type: application/json
AUTH_TOKEN: your_webhook_auth_token
X-Fluid-Shop: your-company-subdomain

Header Descriptions

  • AUTH_TOKEN: Your webhook verification token
  • X-Fluid-Shop: Your company's Fluid subdomain identifier
  • Content-Type: Always application/json

Verification Methods

Method 1: Token Verification

Compare the AUTH_TOKEN header with your stored webhook token:

require 'openssl'

def verify_webhook(request)
  received_token = request.headers['AUTH_TOKEN']
  expected_token = 'your_stored_webhook_token'
  
  # Use secure comparison to prevent timing attacks
  ActiveSupport::SecurityUtils.secure_compare(received_token, expected_token)
end

Method 2: Company Verification

Verify the X-Fluid-Shop header matches your expected company:

def verify_company(request)
  received_shop = request.headers['X-Fluid-Shop']
  expected_shop = 'your-company-subdomain'
  
  received_shop == expected_shop
end

Token Management

Retrieving Your Auth Token

Your webhook auth token is returned when you create a webhook:

curl -X POST "https://api.fluid.app/api/company/webhooks" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resource": "order",
    "event": "created",
    "url": "https://your-app.com/webhooks/orders"
  }'

Response includes the auth_token:

{
  "webhook": {
    "id": 123,
    "auth_token": "abc123def456",
    ...
  }
}

Custom Auth Tokens

You can specify a custom auth token when creating webhooks:

curl -X POST "https://api.fluid.app/api/company/webhooks" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resource": "order",
    "event": "created",
    "url": "https://your-app.com/webhooks/orders",
    "auth_token": "your_custom_token_here"
  }'

Rotating Tokens

Update your webhook's auth token periodically for security:

curl -X PUT "https://api.fluid.app/api/company/webhooks/123" \
  -H "Authorization: Bearer YOUR_COMPANY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "auth_token": "new_secure_token_here"
    }
  }'

Security Best Practices

Endpoint Security

  1. Use HTTPS Only: Never use HTTP endpoints for webhooks
  2. Validate All Requests: Always verify the AUTH_TOKEN header
  3. Implement Rate Limiting: Protect against potential abuse
  4. Log Webhook Calls: Monitor for suspicious activity

Token Security

  1. Store Tokens Securely: Use environment variables or secure key management
  2. Rotate Regularly: Change tokens periodically
  3. Use Strong Tokens: Generate cryptographically secure random tokens
  4. Limit Token Scope: Use different tokens for different webhook types if possible

Example Secure Webhook Handler

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_webhook_auth
  
  def orders
    payload = JSON.parse(request.body.read)
    process_order_event(payload)
    
    head :ok
  rescue JSON::ParserError
    head :bad_request
  end
  
  private
  
  def verify_webhook_auth
    received_token = request.headers['AUTH_TOKEN']
    expected_token = ENV['WEBHOOK_AUTH_TOKEN']
    received_shop = request.headers['X-Fluid-Shop']
    expected_shop = ENV['COMPANY_SUBDOMAIN']
    
    unless received_token && expected_token
      head :unauthorized and return
    end
    
    unless received_shop == expected_shop
      head :unauthorized and return
    end
    
    unless ActiveSupport::SecurityUtils.secure_compare(received_token, expected_token)
      head :unauthorized and return
    end
  end
  
  def process_order_event(payload)
    event_name = payload.dig('payload', 'event_name')
    order_data = payload.dig('payload', 'order')
    
    case event_name
    when 'order_created'
      handle_new_order(order_data)
    when 'order_shipped'
      handle_order_shipped(order_data)
    # ... handle other events
    end
  end
  
  def handle_new_order(order_data)
    # Process new order
    Rails.logger.info "Processing new order: #{order_data['id']}"
  end
  
  def handle_order_shipped(order_data)
    # Process shipped order
    Rails.logger.info "Order shipped: #{order_data['id']}"
  end
end

IP Allowlisting

For additional security, you may want to restrict webhook calls to specific IP addresses. Contact Fluid support for current webhook IP ranges if you need to implement IP allowlisting.

Best Practices

Implementation Patterns

Idempotency

Webhooks may be delivered more than once. Implement idempotency to handle duplicate events:

class WebhookProcessor
  def self.handle_webhook(payload)
    event_id = payload['id']
    
    # Check if we've already processed this event
    return { status: 'already_processed' } if already_processed?(event_id)
    
    # Process the event
    result = process_event(payload)
    
    # Mark as processed
    mark_as_processed(event_id)
    
    result
  end
  
  private
  
  def self.already_processed?(event_id)
    ProcessedWebhook.exists?(event_id: event_id)
  end
  
  def self.mark_as_processed(event_id)
    ProcessedWebhook.create!(event_id: event_id, processed_at: Time.current)
  end
  
  def self.process_event(payload)
    # Event processing logic here
    { status: 'success' }
  end
end

Asynchronous Processing

Process webhooks asynchronously to avoid timeouts:

class WebhooksController < ApplicationController
  def receive_webhook
    payload = JSON.parse(request.body.read)
    
    # Queue for background processing
    WebhookProcessorJob.perform_later(payload)
    
    head :ok
  end
end

class WebhookProcessorJob < ApplicationJob
  queue_as :webhooks
  
  def perform(payload)
    # Heavy processing here
    process_order_fulfillment(payload)
  end
  
  private
  
  def process_order_fulfillment(payload)
    event_name = payload.dig('payload', 'event_name')
    order_data = payload.dig('payload', 'order')
    
    case event_name
    when 'order_created'
      OrderFulfillmentService.new(order_data).process
    when 'order_shipped'
      ShippingNotificationService.new(order_data).send_notification
    end
  end
end

Response Handling

Always return appropriate HTTP status codes:

class WebhooksController < ApplicationController
  def webhook_handler
    payload = JSON.parse(request.body.read)
    
    unless validate_payload(payload)
      head :bad_request and return
    end
    
    process_webhook(payload)
    head :ok
    
  rescue JSON::ParserError, ValidationError
    head :bad_request
  rescue ProcessingError
    head :unprocessable_entity
  rescue StandardError => e
    Rails.logger.error "Webhook processing error: #{e.message}"
    head :internal_server_error
  end
  
  private
  
  def validate_payload(payload)
    payload.present? && payload['payload'].present?
  end
  
  def process_webhook(payload)
    WebhookProcessor.handle_webhook(payload)
  end
end

Error Handling and Retry Strategies

Webhook Delivery Behavior

  • Fluid will retry failed webhook deliveries
  • Retries use exponential backoff
  • Maximum retry attempts: 5
  • Timeout per request: 10 seconds

Handling Failures

Implement proper error handling in your webhook endpoints:

class WebhookProcessor
  def self.process_webhook(payload)
    # Main processing logic
    result = handle_event(payload)
    Rails.logger.info "Successfully processed webhook: #{payload['id']}"
    result
    
  rescue ValidationError => e
    Rails.logger.error "Validation error: #{e.message}"
    raise # Re-raise to return 400 status
    
  rescue ExternalServiceError => e
    Rails.logger.error "External service error: #{e.message}"
    # Don't re-raise - return success to prevent retries
    { status: 'deferred' }
    
  rescue StandardError => e
    Rails.logger.error "Unexpected error: #{e.message}"
    raise # Re-raise to trigger retry
  end
  
  private
  
  def self.handle_event(payload)
    event_name = payload.dig('payload', 'event_name')
    
    case event_name
    when 'order_created'
      OrderProcessor.new(payload.dig('payload', 'order')).process
    when 'cart_updated'
      CartProcessor.new(payload.dig('payload', 'cart')).process
    else
      Rails.logger.warn "Unknown event type: #{event_name}"
    end
    
    { status: 'success' }
  end
end

Retry Logic

Implement your own retry logic for external service calls:

class ExternalServiceCaller
  MAX_RETRIES = 3
  
  def self.call_with_retry(data)
    (0...MAX_RETRIES).each do |attempt|
      begin
        return external_service_call(data)
      rescue ExternalServiceError => e
        raise if attempt == MAX_RETRIES - 1
        
        # Exponential backoff with jitter
        delay = (2 ** attempt) + rand
        sleep(delay)
        
        Rails.logger.warn "External service call failed (attempt #{attempt + 1}): #{e.message}"
      end
    end
  end
  
  private
  
  def self.external_service_call(data)
    # Make the actual external service call
    HTTParty.post('https://external-api.com/webhook', 
                  body: data.to_json, 
                  headers: { 'Content-Type' => 'application/json' })
  end
end

Performance Considerations

Optimize Webhook Processing

  1. Keep Handlers Lightweight: Minimize processing time in webhook handlers
  2. Use Background Jobs: Queue heavy processing for background workers
  3. Database Optimization: Use efficient queries and indexing
  4. Caching: Cache frequently accessed data

Example Optimized Handler

class WebhooksController < ApplicationController
  def order_webhook
    payload = JSON.parse(request.body.read)
    
    # Quick validation and queuing
    unless quick_validate(payload)
      head :bad_request and return
    end
    
    # Queue for background processing using Sidekiq
    OrderWebhookJob.perform_async(payload)
    
    head :ok
  end
  
  private
  
  def quick_validate(payload)
    payload.present? && 
    payload['payload'].present? && 
    payload.dig('payload', 'order').present?
  end
end

# Background worker
class OrderWebhookJob
  include Sidekiq::Job
  
  def perform(payload)
    process_order_webhook(payload)
  end
  
  private
  
  def process_order_webhook(payload)
    order_data = payload.dig('payload', 'order')
    event_name = payload.dig('payload', 'event_name')
    
    OrderWebhookProcessor.new(order_data, event_name).process
  end
end

Monitoring and Alerting

Set up monitoring for your webhook endpoints:

class WebhooksController < ApplicationController
  around_action :monitor_webhook_performance
  
  def webhook_handler
    payload = JSON.parse(request.body.read)
    event_type = payload.dig('payload', 'event_name') || 'unknown'
    
    process_webhook(payload)
    
    # Log successful processing
    Rails.logger.info "Webhook processed successfully", 
                      event_type: event_type, 
                      webhook_id: payload['id']
    
    # Increment success counter
    webhook_counter.increment(labels: { event_type: event_type, status: 'success' })
    
    head :ok
    
  rescue StandardError => e
    # Log error
    Rails.logger.error "Webhook processing failed", 
                       event_type: event_type, 
                       error: e.message,
                       webhook_id: payload&.dig('id')
    
    # Increment error counter
    webhook_counter.increment(labels: { event_type: event_type, status: 'error' })
    
    head :internal_server_error
  end
  
  private
  
  def monitor_webhook_performance
    start_time = Time.current
    yield
  ensure
    duration = Time.current - start_time
    webhook_duration_histogram.observe(duration)
  end
  
  def webhook_counter
    @webhook_counter ||= Prometheus::Client.registry.counter(
      :webhook_requests_total, 
      docstring: 'Total webhook requests',
      labels: [:event_type, :status]
    )
  end
  
  def webhook_duration_histogram
    @webhook_duration_histogram ||= Prometheus::Client.registry.histogram(
      :webhook_processing_seconds,
      docstring: 'Webhook processing time'
    )
  end
end

Testing Webhooks

Local Development

Use tools like ngrok to expose local endpoints for testing:

# Install ngrok
npm install -g ngrok

# Expose local port 3000
ngrok http 3000

# Use the generated URL for webhook configuration
# https://abc123.ngrok.io/webhooks/orders

Webhook Testing Tools

Create test endpoints to verify webhook delivery:

class WebhooksController < ApplicationController
  def test_webhook
    payload = JSON.parse(request.body.read)
    
    # Log the received payload
    Rails.logger.info "Received webhook: #{JSON.pretty_generate(payload)}"
    
    # Store for inspection in development
    if Rails.env.development?
      filename = "webhook_#{Time.current.to_i}.json"
      File.write(Rails.root.join('tmp', filename), JSON.pretty_generate(payload))
    end
    
    head :ok
  rescue JSON::ParserError => e
    Rails.logger.error "Invalid JSON in webhook: #{e.message}"
    head :bad_request
  end
end

Integration Testing

Test your webhook handlers with sample payloads:

require 'rails_helper'

RSpec.describe WebhookProcessor do
  describe '.process_webhook' do
    let(:sample_payload) do
      {
        'payload' => {
          'event_name' => 'order_created',
          'order' => {
            'id' => 12345,
            'order_number' => 'TEST-001',
            'amount' => 99.99
          }
        }
      }
    end
    
    it 'processes order created webhook successfully' do
      allow(OrderProcessor).to receive(:new).and_return(double(process: true))
      
      result = described_class.process_webhook(sample_payload)
      
      expect(result[:status]).to eq('success')
      expect(OrderProcessor).to have_received(:new).with(sample_payload.dig('payload', 'order'))
    end
    
    it 'handles validation errors appropriately' do
      invalid_payload = { 'payload' => {} }
      
      expect { described_class.process_webhook(invalid_payload) }
        .to raise_error(ValidationError)
    end
  end
end

# Controller test
RSpec.describe WebhooksController, type: :controller do
  describe 'POST #orders' do
    let(:valid_payload) { { payload: { event_name: 'order_created', order: { id: 1 } } } }
    
    before do
      request.headers['AUTH_TOKEN'] = 'valid_token'
      request.headers['X-Fluid-Shop'] = 'test-shop'
      allow(ENV).to receive(:[]).with('WEBHOOK_AUTH_TOKEN').and_return('valid_token')
      allow(ENV).to receive(:[]).with('COMPANY_SUBDOMAIN').and_return('test-shop')
    end
    
    it 'processes valid webhook and returns 200' do
      post :orders, body: valid_payload.to_json
      expect(response).to have_http_status(:ok)
    end
    
    it 'returns 401 for invalid auth token' do
      request.headers['AUTH_TOKEN'] = 'invalid_token'
      post :orders, body: valid_payload.to_json
      expect(response).to have_http_status(:unauthorized)
    end
  end
end

Need Help?

  • API Documentation: Check our complete API reference
  • Support: Contact our support team for webhook-specific questions
  • Community: Join our developer community for tips and best practices

Ready to implement webhooks? Start by setting up your first webhook and testing it with our sample payloads!