Webhook Logging in CM Admin
Overviewâ
CM Admin provides a base WebhooksController that handles incoming webhook requests with async processing and a debug UI. Vendor-specific webhook controllers inherit from this base class and override the methods they need.
Configurationâ
Enabling Webhook Loggingâ
By default, webhook logging is disabled. To enable it, add the following to your initializer:
CmAdmin.config.enable_webhook_processing = true
When enabled, webhook events are stored as CmWebhookEvent records and processed asynchronously via ProcessWebhookEventJob. The Webhook Events admin panel shows all logged events with their service, event type, payload, status, and error information.
When disabled, webhooks are still processed â but synchronously via an overridden process_webhook (no CmWebhookEvent record is created). This separates logging from processing: the flag controls whether events are persisted, not whether they're handled.
Postmark Webhook Credentialsâ
The built-in Postmark webhook integration uses HTTP Basic Auth. Credentials are stored in Rails encrypted credentials. Add the following to your credentials file (rails credentials:edit):
postmark:
webhook_username: your_username
webhook_password: your_password
The controller reads these via Rails.application.credentials.dig(:postmark, :webhook_username) and Rails.application.credentials.dig(:postmark, :webhook_password).
Setting Up the Database Tableâ
Run the generator to create the migration:
rails g cm_admin:add_webhook_events
rails db:migrate
Note: Webhook payloads are stored as ActiveStorage attachments (
raw_payload). Ensure ActiveStorage is installed and configured in your host app (rails active_storage:install).
Host App Servicesâ
The gem defines cm_service for gem-managed services (e.g. postmark). Host apps can define their own host_service enum for their own webhook sources:
# config/initializers/cm_admin.rb
Rails.application.reloader.to_prepare do
CmWebhookEvent.class_eval do
enum :host_service, { chatgpt: 0, gemini: 1, gemini_batch: 2 }
end
end
Integer values are scoped per-column â cm_service and host_service are independent and may use overlapping integers (e.g. cm_service: 0 = postmark, host_service: 0 = chatgpt).
Usageâ
Base Controllerâ
CmAdmin::WebhooksController provides the shared flow for all webhook endpoints:
When enable_webhook_processing is enabled (default flow):
- Authenticates the request via the
authenticate_webhookbefore_action (override in subclass â unauthenticated requests never createCmWebhookEventrecords) - Finds the existing webhook event via
find_existing_webhook_event(override in subclass) - Creates one via
create_webhook_eventwhen no pre-created event exists (override in subclass â e.g. Postmark) - Attaches the incoming payload via
attach_webhook_payload(skipped whenraw_payloadis already attached) - Updates the event status to
received(only if it isn't alreadyreceivedorprocessed) - Enqueues
ProcessWebhookEventJobfor non-log-only events - Returns
200 OKimmediately
When enable_webhook_processing is disabled:
- No
CmWebhookEventrecord is created process_webhookis called â the default implementation is a no-op, so override it in the subclass to processwebhook_payloadsynchronously (e.g. Postmark callsPostmarkWebhookService.process(webhook_payload))- Returns
200 OK
Host apps should pre-create webhook events before calling external services (e.g. embedding event.id in the callback URL); the controller then finds the existing event via find_existing_webhook_event. For gem-managed services like Postmark where no pre-creation is possible, the subclass implements create_webhook_event to create the record when the webhook arrives.
The job calls event.process! which:
- Returns early if the event is log-only (no service set)
- Calls
perform_cm_processing(dispatches toprocess_#{cm_service}_event) - Calls
perform_host_processing(empty by default â override in host app via class_eval) - Updates status to
processedon success (witherror_messageset fromprocessing_notewhen provided) orfailedwith the raised error message on failure
Methods to Overrideâ
| Method | Required | Description |
|---|---|---|
authenticate_webhook | Recommended | Verifies the request before anything is logged or processed. Registered as a before_action on the base controller â override with your auth logic (HTTP Basic, HMAC signature, etc.) and render/abort to reject. Defaults to no-op. |
find_existing_webhook_event | Yes | Returns the pre-created CmWebhookEvent or nil. Defaults to nil. |
create_webhook_event | When not pre-created | Creates a CmWebhookEvent when find_existing_webhook_event returns nil. Used by gem-managed services that cannot pre-create (e.g. Postmark). Defaults to nil. |
attach_webhook_payload | Recommended | Attaches the incoming webhook payload to raw_payload. Called after the event is found or created. Default implementation attaches webhook_payload.to_json unless raw_payload is already attached. Override if you need custom attachment logic. |
process_webhook | When logging disabled | Processes the webhook synchronously when enable_webhook_processing is false. The default implementation enqueues ProcessWebhookEventJob for logged events â call super to keep async processing. |
webhook_event_type | Yes | Returns the event type string. Defaults to params[:event_type]. |
webhook_payload | Yes | Returns the webhook payload as a hash. Must be implemented by every subclass to explicitly permit safe fields. Stored as a JSON attachment via raw_payload. |
webhook_event_id | Optional | Returns the external ID for deduplication. Defaults to nil. |
webhook_eventable | Optional | Returns the associated record (polymorphic). Defaults to nil. |
Example: Custom Webhook Controllerâ
module CmAdmin
class StripeWebhooksController < WebhooksController
private
# authenticate_webhook is already wired as a before_action by the base
# controller â just override the method with your auth logic.
def authenticate_webhook
# Your authentication logic here
end
def find_existing_webhook_event
::CmWebhookEvent.find_by(host_service: :stripe, event_id: webhook_event_id)
end
def webhook_event_type
params[:type]
end
def webhook_event_id
request.headers['Stripe-Idempotency-Key']
end
end
end
Pre-create Pattern (Host App)â
Host apps pre-create webhook events before calling external services, embedding the event ID in the callback URL. When the webhook arrives, the controller finds the existing event and processes it:
# Before calling the external service:
event = CmWebhookEvent.create!(
host_service: :chatgpt,
event_type: 'BrightData',
event_id: 'evt_123',
status: :created
)
event.raw_payload.attach(
io: StringIO.new('{}'),
filename: 'payload.json',
content_type: 'application/json'
)
# In the webhook controller:
module CmAdmin
class BrightDataWebhooksController < WebhooksController
private
def find_existing_webhook_event
::CmWebhookEvent.find_by(event_id: params[:event_id])
end
def webhook_event_type
'BrightData'
end
end
end
Routingâ
Webhook endpoints are namespaced under webhooks:
# config/routes.rb (inside CmAdmin::Engine.routes.draw)
scope :webhooks do
post ':service/:event_id', to: CmAdmin::WebhookRouter, as: :webhook_event
post ':service', to: CmAdmin::WebhookRouter, as: :webhook
end
CmAdmin::WebhookRouter resolves :service to a controller by convention â service=postmark dispatches to CmAdmin::PostmarkWebhooksController#receive, service=bright_data to CmAdmin::BrightDataWebhooksController#receive â and responds with 404 when no matching WebhooksController subclass exists. The optional :event_id segment is exposed as params[:event_id], which pre-create flows use to look up the event.
Any CmAdmin::XxxWebhooksController < CmAdmin::WebhooksController is therefore automatically reachable at POST /cm_admin/webhooks/xxx (and POST /cm_admin/webhooks/xxx/:event_id) â no host-app route needed. Add a custom route only when you need a non-conventional path:
# config/routes.rb (host app)
post '/webhook/bright_data/:event_id', to: 'cm_admin/bright_data_webhooks#receive'
Built-in Postmark Integrationâ
CM Admin includes a PostmarkWebhooksController that inherits from WebhooksController. It:
- Authenticates using HTTP Basic Auth with credentials from Rails encrypted credentials
- Validates the presence of
RecordTypeparameter - Creates the webhook event with
cm_service: :postmarkandstatus: :created(Postmark sends separate webhooks per recipient for the sameMessageID, so each incoming webhook is logged as a separate event âfind_existing_webhook_eventis not used) - Stores
MessageIDas theevent_id - Links to the associated
CmEmailLogvia polymorphiceventable - When logging is disabled, processes inline via
PostmarkWebhookService.process(webhook_payload)
The route is available at POST /cm_admin/webhooks/postmark (via the generic webhook router).
CmWebhookEvent Modelâ
The CmWebhookEvent model stores the following fields:
| Field | Type | Description |
|---|---|---|
cm_service | integer (enum) | Gem-managed service (e.g. postmark: 0) |
host_service | integer (enum) | Host-app service â defined by host app |
event_type | string | Event type from the webhook payload (required) |
raw_payload | ActiveStorage attachment | Full webhook payload stored as a file attachment |
status | integer (enum) | created (0), received (1), processed (2), or failed (3) |
event_id | string | External ID for deduplication |
uuid | string | Stable identifier assigned on creation (SecureRandom, backed by a unique index) |
error_message | text | Error message if processing failed |
processed_at | datetime | When the webhook was processed |
eventable | polymorphic | Optional associated record |
The payload method reads and parses the attachment on demand (cached per instance):
event.payload # => { "MessageID" => "...", "RecordType" => "Delivery", ... }
There are no unique constraints on incoming webhook events (the only unique index is on uuid) â each incoming webhook is logged as a separate record. Processing idempotency (e.g. updating the same CmEmailLog recipient status for duplicate Postmark webhooks) must be handled by the service-specific processing logic (PostmarkWebhookService â update_recipient_delivery_status).
Processing Dispatchâ
When process! is called (via ProcessWebhookEventJob):
- Returns early if the event is log-only (neither
cm_servicenorhost_serviceset) - perform_cm_processing â if
cm_serviceis set, callsprocess_#{cm_service}_event(e.g.process_postmark_event) - perform_host_processing â empty by default; host apps override via class_eval to add app-specific processing
- Updates status to
processedon success orfailedon error
Log-only Eventsâ
Events with neither cm_service nor host_service set are treated as log-only â they record the webhook receipt and payload without any processing. The process! method returns early and the status stays at received. This is useful for host apps that handle processing separately and just want the audit trail in the admin UI.
Admin UIâ
When webhook logging is enabled, a Webhook Events section appears in the CM Admin sidebar with:
- Index view â filterable by search (ID, UUID, event type, event ID, error), status, service, event type, and created date
- Show view â detailed view with UUID, payload, error information, and processing timestamps
- Status badges â color-coded: gray (created), blue (received), green (processed), red (failed)