Droplet Subscription Webhook Guide
This guide explains how to build a droplet that listens for subscription webhooks. The example droplet registers for the subscription_started event and updates other subscriptions for the same customer to bill on the 15th of the month, so all of that customer's subscriptions process on the same day.
Overview
This guide covers:
- Creating a droplet and registering its install/uninstall lifecycle webhooks
- Receiving the
droplet_installedlifecycle webhook and exchanging the one-timeexchange_tokenfor a permanent installation token - Subscribing to the
subscription_startedbusiness webhook - Verifying inbound webhook requests with HMAC-SHA256
- Calling Fluid's
/api/subscriptionsendpoints from your droplet to read and update subscription data
Prerequisites
- A Fluid company account and a company token (prefix
C-) capable of creating droplets - A publicly reachable HTTPS endpoint to receive webhooks (HTTPS is enforced in production)
- Basic knowledge of webhook handling, HMAC signature verification, and subscription management
Architecture Overview
Step 1: Create the Droplet
Create a droplet record. The embed_url is the URL Fluid uses to embed your droplet inside the admin UI. install_webhook_url and uninstall_webhook_url receive lifecycle events; they must be HTTPS in production.
curl -X POST "https://api.fluid.app/api/droplets" \ -H "Authorization: Bearer C-YOUR_COMPANY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "droplet": { "name": "Subscription Billing Synchronizer", "embed_url": "https://your-app.com/droplet/subscription-sync", "install_webhook_url": "https://your-app.com/webhooks/droplet/installed", "uninstall_webhook_url": "https://your-app.com/webhooks/droplet/uninstalled", "settings": { "marketplace_page": { "title": "Subscription Billing Synchronizer", "summary": "Synchronizes customer subscription billing dates to the 15th of each month", "logo_url": "https://your-app.com/logo.svg" }, "details_page": { "title": "Subscription Billing Synchronizer", "summary": "Keeps all customer subscriptions on the same billing cycle", "logo_url": "https://your-app.com/big-logo.svg", "features": [ { "name": "Automatic Synchronization", "summary": "Updates subscription billing dates", "details": "When a new subscription is created, other subscriptions for that customer are updated to bill on the 15th of the month" } ] } }, "requested_scopes": ["main", "orders", "settings"] } }'
requested_scopes must include settings if you intend to register webhooks from your droplet using its installation token (Step 5) — POST /api/company/webhooks requires the developer:update permission, which sits inside the settings scope group. Drop settings only if you plan to register webhooks using a company token (C-…) instead.
You can also create a droplet via the Droplet Marketplace → Create Droplet.
When a droplet is created, Fluid generates a webhook_secret (prefix dws_) — this is the HMAC key used to sign lifecycle webhooks delivered to your install_webhook_url and uninstall_webhook_url. Store this secret securely; you will use it to verify lifecycle webhooks in Step 3.
Step 2: Install the Droplet
Install the droplet against a company:
- Open the Droplet Marketplace
- Select your droplet
- Click Install Droplet
Installation creates a DropletInstallation record. Fluid then POSTs a droplet_installed event to your install_webhook_url.
Step 3: Handle the droplet_installed Lifecycle Webhook
When a company installs your droplet, Fluid sends a signed POST to install_webhook_url.
Request headers
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Fluid-Shop | The installing company's fluid_shop slug |
X-Fluid-Timestamp | Unix timestamp (seconds, as string) |
X-Fluid-Signature | HMAC-SHA256(droplet.webhook_secret, "{timestamp}.{raw_body}") as a lowercase hex string |
Request body
{ "id": 12345, "identifier": "dropletinstallation_AbCdEf...", "name": "droplet_installed", "payload": { "event_name": "droplet_installed", "schema_version": 1, "schema_hash": "...", "contract_version": "v2", "company_id": 42, "resource_name": "DropletInstallation", "resource": "droplet", "event": "installed", "company": { "droplet_installation_uuid": "dri_AbCdEf...", "droplet_uuid": "drp_GhIjKl...", "fluid_company_id": 42, "fluid_shop": "acme", "name": "Acme Inc", "credentials": { "exchange_token": "dex_oneTimeOpaqueToken...", "exchange_token_expires_at": "2026-05-18T15:00:00Z", "exchange_endpoint": "/api/droplet_installations/exchange" } } }, "timestamp": "2026-05-18T14:30:45Z" }
The exact fields under payload.company come from DropletInstallationBlueprint in the :lifecycle_installed_v2 view. The credentials.exchange_token is a one-time token your droplet exchanges (Step 4) for a permanent installation token.
Verify the signature
class Webhooks::Droplet::InstalledController < ApplicationController skip_before_action :verify_authenticity_token def create raw_body = request.raw_post return head :request_timeout unless fresh_timestamp? return head :unauthorized unless valid_signature?(raw_body) # Acknowledge fast; do the exchange + persistence in a Sidekiq job so a # transient DB error does not consume the single-use exchange_token and # permanently break the install. See Step 4. Droplet::InstallJob.perform_later(JSON.parse(raw_body)) head :ok end private def valid_signature?(raw_body) timestamp = request.headers["X-Fluid-Timestamp"] provided = request.headers["X-Fluid-Signature"].to_s secret = ENV.fetch("FLUID_DROPLET_WEBHOOK_SECRET") # the dws_ value from droplet creation expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") ActiveSupport::SecurityUtils.secure_compare(expected, provided) end def fresh_timestamp? timestamp = request.headers["X-Fluid-Timestamp"].to_i (Time.now.to_i - timestamp).abs <= 300 # 5-minute tolerance end end
Use request.raw_post — recomputing over a parsed-and-re-serialized body will not match the signature.
Uninstall (symmetric)
When a company removes your droplet, Fluid POSTs a droplet_uninstalled event to the uninstall_webhook_url you supplied in Step 1. The headers, HMAC scheme, signing key (droplet.webhook_secret), and 300-second freshness window are all identical to the install case — only the name, event, and payload differ. Returning 2xx (any 200–299) tells Fluid to finalize the uninstall; returning 4xx is treated as terminal (Fluid finalizes anyway, since the droplet owner has been notified); returning 5xx, 429, or timing out causes Fluid to retry the delivery via Sidekiq. After the uninstall is finalized, the installation's dit_… token stops working — clean up any stored credentials for that company.
Step 4: Exchange the One-Time Token for a Permanent Installation Token
The exchange_token is single-use. Exchange it for a permanent installation token (prefix dit_) before it expires. The exchange endpoint responds with:
{ "droplet_installation": { "droplet_installation_uuid": "dri_...", "droplet_uuid": "drp_...", "fluid_company_id": 42, "fluid_shop": "acme" }, "credentials": { "authentication_token": "dit_...", "webhook_verification_token": "wvt_...", "issued_at": "2026-05-18T14:30:45Z", "token_type": "bearer" } }
class Droplet::InstallJob < ApplicationJob queue_as :webhooks def perform(install_payload) company = install_payload.fetch("payload").fetch("company") uuid = company.fetch("droplet_installation_uuid") # Pre-create the tenant row with NULL tokens BEFORE the exchange. On a # Sidekiq retry after a partial failure, this find_or_create_by! resolves # to the existing row instead of duplicating, and the credentials check # below short-circuits if the previous attempt's exchange already wrote # them. Requires a unique index on droplet_tenants.droplet_installation_uuid. tenant = DropletTenant.find_or_create_by!(droplet_installation_uuid: uuid) do |t| t.company_id = company.fetch("fluid_company_id") t.fluid_shop = company.fetch("fluid_shop") end # Idempotent: if a prior attempt completed the exchange and wrote the # tokens, do nothing. The exchange_token is single-use, so we cannot # exchange again — but we also do not need to. return if tenant.installation_token.present? response = HTTParty.post( "https://api.fluid.app#{company.dig('credentials', 'exchange_endpoint')}", headers: { "Content-Type" => "application/json" }, body: { exchange_token: company.dig("credentials", "exchange_token") }.to_json, timeout: 20, # stay inside Fluid's 30-second lifecycle webhook window ) raise "Exchange failed: #{response.code} #{response.body}" unless response.success? credentials = JSON.parse(response.body).fetch("credentials") tenant.update!( installation_token: credentials.fetch("authentication_token"), # dit_… — call Fluid back as this company webhook_verification_token: credentials.fetch("webhook_verification_token"), # wvt_… — HMAC key for business webhooks ) end end
The failure mode you cannot recover from automatically: the exchange succeeds, Sidekiq exhausts retries on the subsequent tenant.update! (e.g. extended DB outage), and the exchange_token is now consumed on Fluid's side. The tenant row still exists with NULL tokens, and the credentials are gone. The job's payload is in Sidekiq's dead set — an operator can extract the credentials from Fluid's audit log and write them by hand, or trigger a reinstall. The pre-created tenant row is the durable handle that lets the operator find the right install.
Store the dit_… token alongside the company_id. You will use it as Authorization: Bearer dit_… in every subsequent call to Fluid for this company.
The example assumes a DropletTenant model on your side with these columns:
# db/migrate/<timestamp>_create_droplet_tenants.rb create_table :droplet_tenants do |t| t.bigint :company_id, null: false t.string :fluid_shop, null: false t.string :droplet_installation_uuid, null: false t.string :installation_token # dit_… — populated by InstallJob after exchange t.string :webhook_verification_token # wvt_… — populated by InstallJob after exchange t.timestamps end add_index :droplet_tenants, :company_id, unique: true # one tenant per company add_index :droplet_tenants, :fluid_shop, unique: true # hot path for verifier lookup in Step 6 add_index :droplet_tenants, :droplet_installation_uuid, unique: true # idempotency key for InstallJob
# app/models/droplet_tenant.rb class DropletTenant < ApplicationRecord end
Index :fluid_shop because every inbound business webhook performs a find_by(fluid_shop: …) during signature verification (Step 6).
Step 5: Register the Subscription Webhook
With the installation token in hand, register a webhook on Fluid for subscription.started:
curl -X POST "https://api.fluid.app/api/company/webhooks" \ -H "Authorization: Bearer dit_YOUR_INSTALLATION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "webhook": { "resource": "subscription", "event": "started", "url": "https://your-app.com/webhooks/subscription-sync", "http_method": "post" } }'
Notes:
resource: "subscription"andevent: "started"are validated againstWebhook::WEBHOOK_EVENTS. Other validsubscriptionevents:paused,cancelled,billed,declined,resumed,resumed_after_payment_fix,reactivated,skipped.- Signing key: because this webhook is owned by your droplet installation, Fluid signs every delivery with the installation's
webhook_verification_token(thewvt_…value returned by the exchange in Step 4). Even if you passauth_tokenin this request, the installation token wins (Webhook#auth_tokenreturns the installation'swvt_…first, the storedauth_tokenonly as a fallback). Use thewvt_…value in your verifier (Step 6) and skip theauth_tokenfield here. - If you'd rather pick your own signing secret, register with a company token (
Bearer C-…) instead, supplyauth_token, and verify against that value. The droplet-installation path is the realistic flow for an installed droplet. http_methoddefaults topost;activedefaults totrue.- URLs must be HTTPS in production; internal/private hosts are rejected.
Step 6: Receive and Verify the subscription_started Webhook
Headers
Every business webhook delivery includes:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Fluid-Shop | The company's fluid_shop slug |
AUTH_TOKEN | The signing key value itself — sent for backward compatibility with the old static-token scheme. Treat as informational only; verify the signature instead. |
X-Fluid-Token | Same value as AUTH_TOKEN |
X-Fluid-Timestamp | Unix timestamp (seconds, as string) |
X-Fluid-Signature | HMAC-SHA256(signing_key, "{timestamp}.{raw_body}") as lowercase hex |
The signing_key is the installation's webhook_verification_token (wvt_…) when the webhook is owned by a droplet installation, otherwise the auth_token you provided when creating the webhook. Verify the signature rather than comparing AUTH_TOKEN directly — the signature covers timestamp and body and is replay-resistant when paired with a freshness check.
Body
{ "id": 9876, "identifier": "commerce::subscription_aBcDeF...", "name": "subscription_started", "payload": { "event_name": "subscription_started", "schema_version": 3, "schema_hash": "...", "company_id": 42, "resource_name": "Commerce::Subscription", "resource": "subscription", "event": "started", "subscription": { "id": 12345, "subscription_token": "sb_AbCdEf...", "status": "active", "next_bill_date": "2026-06-01T00:00:00Z", "customer": { "id": 789, "email": "customer@example.com" }, "subscription_plan": { "id": 10, "name": "Monthly Premium", "billing_interval_unit": "month" } } }, "timestamp": "2026-05-18T14:30:45Z" }
The full set of fields under payload.subscription is defined by Commerce::SubscriptionBlueprinter in the :with_associations view (status, prices, dates, customer, plan, address, payment_method, etc.).
Verifier
class Webhooks::SubscriptionSyncController < ApplicationController skip_before_action :verify_authenticity_token def create raw_body = request.raw_post return head :request_timeout unless fresh_timestamp? return head :unauthorized unless valid_signature?(raw_body, signing_key) payload = JSON.parse(raw_body) subscription = payload.dig("payload", "subscription") return head :ok unless subscription SubscriptionSyncJob.perform_later(payload) head :ok end private def signing_key # Look up the wvt_ value stored when we exchanged the install token in Step 4. # Use find_by (not find_by!) so unknown shops fail closed with 401 below, # rather than raising RecordNotFound and returning 404/500. Also guard # against a missing X-Fluid-Shop header so we never look up `IS NULL`. shop = request.headers["X-Fluid-Shop"] return nil if shop.blank? DropletTenant.find_by(fluid_shop: shop)&.webhook_verification_token end def valid_signature?(raw_body, secret) return false if secret.nil? timestamp = request.headers["X-Fluid-Timestamp"] provided = request.headers["X-Fluid-Signature"].to_s expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") ActiveSupport::SecurityUtils.secure_compare(expected, provided) end def fresh_timestamp? timestamp = request.headers["X-Fluid-Timestamp"].to_i (Time.now.to_i - timestamp).abs <= 300 end end
The verifier looks up the signing key per-company via X-Fluid-Shop because the wvt_… value is unique to each DropletInstallation. If you registered the webhook with a company token and supplied an auth_token, replace signing_key with that stored secret instead.
Step 7: Call Fluid Back to Synchronize Billing Dates
To act on the event, list the customer's other subscriptions and update their next_bill_date.
List subscriptions for a customer
GET https://api.fluid.app/api/subscriptions?customer_id=789&status=active Authorization: Bearer dit_YOUR_INSTALLATION_TOKEN
Response (truncated):
{ "subscriptions": [ { "id": 12345, "subscription_token": "sb_AbCdEf...", "status": "active", "next_bill_date": "2026-06-01T00:00:00Z", "subscription_plan": { "id": 10, ... }, "customer": { "id": 789, ... }, "currency": { "id": 1, ... }, "variant": { "id": 333, "product": { ... } } } ], "meta": { "current_page": 1, "total_pages": 1, "total_count": 1, "stats": { ... } } }
The list endpoint uses the :api_index blueprinter view, which intentionally omits address and payment_method to keep the response small. Before patching a subscription you must fetch it individually to get address_id and payment_method_id:
Fetch a single subscription
GET https://api.fluid.app/api/subscriptions/sb_AbCdEf... Authorization: Bearer dit_YOUR_INSTALLATION_TOKEN
The show response uses the :with_associations_and_customer_extended blueprint view, which composes :with_associations plus extra customer detail. That includes address, payment_method, and the full set of associations needed for an update.
Update next_bill_date on a single subscription
The update endpoint takes the subscription token (not numeric id) as the path param, and it requires several fields in the body in addition to next_bill_date:
PATCH https://api.fluid.app/api/subscriptions/sb_AbCdEf... Authorization: Bearer dit_YOUR_INSTALLATION_TOKEN Content-Type: application/json { "subscription": { "subscription_plan_id": 10, "customer_id": 789, "variant_id": 333, "address_id": 555, "payment_method_id": 444, "next_bill_date": "2026-06-15" } }
The subscription_plan_id, customer_id, variant_id, address_id, and payment_method_id fields are required by Commerce::Api::Subscriptions::UpdateAction. Take them from the per-subscription show response (the list response omits address and payment_method). Sending only next_bill_date will be rejected with a 400.
next_bill_date accepts a date string; Fluid preserves the existing time-of-day in the subscription's timezone. A value in the past is rejected.
Synchronizer service
class SubscriptionSynchronizer BILLING_DAY = 15 def initialize(payload) @subscription = payload.fetch("subscription") @customer_id = @subscription.dig("customer", "id") @company_id = payload.fetch("company_id") end def synchronize other_subs = list_other_subscriptions target = next_billing_date other_subs.each { |sub| update_bill_date(sub, target) } end private # Paginate through every active subscription for the customer — the list # endpoint defaults to 25 per page, and silently skipping later pages would # leave heavy customers' billing dates unsynchronized. def list_other_subscriptions page = 1 all = [] loop do response = fluid.get("/api/subscriptions", query: { customer_id: @customer_id, status: "active", page: page }) raise "List failed: #{response.code}" unless response.success? body = JSON.parse(response.body) all.concat(body.fetch("subscriptions")) break if page >= body.dig("meta", "total_pages").to_i page += 1 end all.reject { |s| s["subscription_token"] == @subscription["subscription_token"] } end # The list endpoint's :api_index view omits address and payment_method, # so we re-fetch each subscription via show to get the full association set # required by the update endpoint. def fetch_full(token) response = fluid.get("/api/subscriptions/#{token}") raise "Show failed: #{response.code}" unless response.success? JSON.parse(response.body).fetch("subscription") end def update_bill_date(stub, target) sub = fetch_full(stub.fetch("subscription_token")) # UpdateAction requires payment_method_id and address_id as integers. # Some subscriptions (e.g. zero-price ones created without a stored # payment method) legitimately have nil here — patching them would 400. unless sub["payment_method"] && sub["address"] Rails.logger.info("Skipping #{sub['subscription_token']}: missing payment_method or address") return end body = { subscription: { subscription_plan_id: sub.dig("subscription_plan", "id"), customer_id: sub.dig("customer", "id"), variant_id: sub.dig("variant", "id"), address_id: sub.dig("address", "id"), payment_method_id: sub.dig("payment_method", "id"), next_bill_date: target.strftime("%Y-%m-%d"), }, } response = fluid.patch("/api/subscriptions/#{sub['subscription_token']}", body: body.to_json) if response.success? Rails.logger.info("Synced #{sub['subscription_token']} → #{target}") else Rails.logger.error("Sync failed for #{sub['subscription_token']}: #{response.code} #{response.body}") end end def next_billing_date today = Date.current this_15 = Date.new(today.year, today.month, BILLING_DAY) today.day <= BILLING_DAY ? this_15 : this_15.next_month end def fluid @fluid ||= FluidClient.new(token: DropletTenant.find_by!(company_id: @company_id).installation_token) end end class FluidClient BASE_URL = "https://api.fluid.app" def initialize(token:) @headers = { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json", } end def get(path, query: {}) HTTParty.get("#{BASE_URL}#{path}", headers: @headers, query: query, timeout: 30) end def patch(path, body:) HTTParty.patch("#{BASE_URL}#{path}", headers: @headers, body: body, timeout: 30) end end
Step 8: Process Asynchronously
Webhook delivery timeouts differ between the two paths:
- Business webhooks (subscription, order, etc.): connect timeout 5s, total timeout 15s.
- Lifecycle webhooks (install/uninstall): connect timeout 5s, total timeout 30s.
In both cases, acknowledge fast and do work in a background job:
class SubscriptionSyncJob < ApplicationJob queue_as :webhooks def perform(webhook_body) SubscriptionSynchronizer.new(webhook_body.fetch("payload")).synchronize end end
# config/routes.rb Rails.application.routes.draw do post "/webhooks/droplet/installed", to: "webhooks/droplet/installed#create" post "/webhooks/droplet/uninstalled", to: "webhooks/droplet/uninstalled#create" post "/webhooks/subscription-sync", to: "webhooks/subscription_sync#create" end
Step 9: Testing
Test the controller with a precomputed signature:
require "rails_helper" RSpec.describe Webhooks::SubscriptionSyncController, type: :request do let(:wvt) { "wvt_test_value" } let(:tenant) { DropletTenant.create!(fluid_shop: "acme", company_id: 1, webhook_verification_token: wvt, installation_token: "dit_x") } let(:body) { { id: 1, name: "subscription_started", payload: { subscription: { id: 99 } } }.to_json } let(:ts) { Time.now.to_i.to_s } let(:sig) { OpenSSL::HMAC.hexdigest("SHA256", wvt, "#{ts}.#{body}") } before { tenant } it "accepts a valid signature" do post "/webhooks/subscription-sync", params: body, headers: { "Content-Type" => "application/json", "X-Fluid-Shop" => "acme", "X-Fluid-Timestamp" => ts, "X-Fluid-Signature" => sig, } expect(response).to have_http_status(:ok) end it "rejects a stale timestamp before computing the signature" do post "/webhooks/subscription-sync", params: body, headers: { "Content-Type" => "application/json", "X-Fluid-Shop" => "acme", "X-Fluid-Timestamp" => (Time.now.to_i - 3600).to_s, "X-Fluid-Signature" => "deadbeef", } expect(response).to have_http_status(:request_timeout) end it "rejects an invalid signature" do post "/webhooks/subscription-sync", params: body, headers: { "Content-Type" => "application/json", "X-Fluid-Shop" => "acme", "X-Fluid-Timestamp" => ts, "X-Fluid-Signature" => "deadbeef", } expect(response).to have_http_status(:unauthorized) end end
Configuration
# Lifecycle webhook signing key — the dws_ secret generated when the droplet was created. # Same for every install of your droplet; safe to keep in env. FLUID_DROPLET_WEBHOOK_SECRET=dws_...
The business webhook signing key is the per-company webhook_verification_token (wvt_…) returned by the install token exchange (Step 4). Persist it in your database alongside company_id and installation_token, and look it up at verification time using X-Fluid-Shop — different installations have different signing keys.
Best Practices
Idempotency
The id field in the webhook body is the WebhookEvent primary key; the identifier field is a unique opaque identifier (e.g. commerce::subscription_…, dropletinstallation_…). Persist identifier and short-circuit if you have seen it before:
return if ProcessedWebhook.exists?(identifier: payload.fetch("identifier")) ProcessedWebhook.create!(identifier: payload.fetch("identifier"))
Signature verification order
- Read
request.raw_postonce and reuse it. - Reject if the timestamp is missing or skewed by more than ~5 minutes.
- Recompute the HMAC over
"{timestamp}.{raw_body}"with your stored secret. - Compare using
ActiveSupport::SecurityUtils.secure_compare. - Only then parse JSON and act.
Per-company secrets
Store the dit_… installation token and the wvt_… webhook verification token per company in your database, keyed by company_id (and fluid_shop for fast lookup at webhook-verification time). Never share these across companies — dit_… grants scoped API access for one company; wvt_… is that company's signing key for inbound business webhooks.
Retries
Lifecycle webhooks (install/uninstall) and business webhooks behave differently here:
- Lifecycle webhooks are retried by Sidekiq on connection errors, timeouts, 5xx responses, and 429s —
DropletLifecycleWebhookre-raises retryable failures so the wrapping job can retry. Non-retryable 4xx responses are treated as terminal and the uninstall is finalized regardless. - Business webhooks (subscription, order, etc.) are NOT retried by Fluid.
Webhook#callcatchesRestClient::Exceptionand otherStandardErrors, records the failure on theWebhookEventrecord, and returns normally — so Sidekiq sees a successful job and never retries. If your receiver returns non-2xx (or times out at 15 seconds), the event is logged on theWebhookEventbut not re-delivered. Build a reconciliation path (e.g. periodic poll of/api/subscriptionsusing yourdit_token, or surface missed events to your support team) for failures you care about.
In both cases, return 2xx only when you have safely accepted the event — typically after enqueuing a job, before doing any slow work.
Troubleshooting
| Symptom | Likely cause |
|---|---|
401 Unauthorized on calls to /api/subscriptions | Wrong or expired installation token; missing Bearer prefix |
| Signature mismatch | Body was parsed and re-serialized before signing; whitespace/encoding changes; signing wrong secret (lifecycle vs business webhook use different keys); using the auth_token you passed at webhook creation when the webhook is owned by a droplet installation — the actual signing key is the installation's wvt_… value |
400 Bad Request on PATCH /api/subscriptions/:token | Missing one of the required ids (subscription_plan_id, customer_id, variant_id, address_id, payment_method_id) |
404 Not Found on PATCH | Path used numeric id; must be subscription_token |
| Webhook never delivers | URL is HTTP in production (HTTPS required) or targets an internal/private host |
| Resource/event rejected on webhook creation | Combination not in Webhook::WEBHOOK_EVENTS; e.g. subscription.created is not a real event — use subscription.started |
Reference
| Item | Value |
|---|---|
| Lifecycle signing key | droplet.webhook_secret (prefix dws_) |
| Business webhook signing key (droplet-installation-owned) | installation webhook_verification_token (prefix wvt_) — takes precedence over any stored auth_token |
| Business webhook signing key (other) | the auth_token supplied at webhook creation |
| Exchange token (one-time, returned in install payload) | prefix dex_ |
| Installation token (call back to Fluid) | prefix dit_ |
| Subscription token | prefix sb_ |
| Company token (alternative caller credential) | prefix C- |
| Signature algorithm | HMAC-SHA256, lowercase hex |
| Signing string | "{unix_timestamp}.{raw_body}" |
| Signature header | X-Fluid-Signature |
| Timestamp header | X-Fluid-Timestamp (Unix seconds, as string) |
| Shop header | X-Fluid-Shop |
| Exchange endpoint (v2 lifecycle) | POST /api/droplet_installations/exchange |
| Subscriptions list | GET /api/subscriptions |
| Subscription update | PATCH /api/subscriptions/:subscription_token |
For additional help, see the Fluid API documentation or contact support.