Class: WebhookLog

Inherits:
ApplicationRecord show all
Defined in:
app/models/webhook_log.rb

Overview

== Schema Information

Table name: webhook_logs
Database name: primary

id :bigint not null, primary key
category :string not null
data :jsonb not null
next_attempt :datetime
notes :text
process_attempts :integer default(0), not null
processed_at :datetime
provider :string not null
resource_type :string
response_data :jsonb
state :string default("ready"), not null
created_at :datetime not null
updated_at :datetime not null
external_id :string
resource_id :integer

Indexes

idx_webhook_logs_provider_category_state (provider,category,state)
idx_webhook_logs_resource (resource_type,resource_id)
idx_webhook_logs_state_created (state,created_at)
index_webhook_logs_on_category (category)
index_webhook_logs_on_external_id (external_id)
index_webhook_logs_on_next_attempt (next_attempt) USING brin

Constant Summary collapse

PROVIDERS =

Providers that can send webhooks

%w[amazon_sp_api assemblyai curri freightquote oxylabs sendgrid sftpgo shipengine switchvox].freeze
CATEGORIES =

Categories by provider

{
  # SP-API notification types (downcased), drained from SQS by
  # AmazonSqsNotificationPollerWorker rather than received over HTTP.
  # See doc/tasks/202607152125_AMAZON_SPAPI_SQS_NOTIFICATIONS.md
  'amazon_sp_api' => %w[
    account_status_changed any_offer_changed b2b_any_offer_changed
    branded_item_content_change detail_page_traffic_event
    external_fulfillment_shipment_status_change
    fba_inventory_availability_changes fba_outbound_shipment_status
    fee_promotion feed_processing_finished fulfillment_order_status
    item_inventory_event_change item_product_type_change item_sales_event_change
    listings_item_issues_change listings_item_mfn_quantity_change
    listings_item_status_change order_change pricing_health
    product_type_definitions_change report_processing_finished
    transaction_update unknown
  ],
  'assemblyai' => %w[transcription_complete],
  'curri' => %w[delivery_update],
  'freightquote' => %w[
    load_created load_booked load_cancelled load_picked_up load_delivered
    order_created order_updated order_rejected order_canceled order_completed
    appointment_updated carrier_arrived carrier_departed
    in_transit in_transit_to_origin pro_number_added chr_tracking_number_published
    unknown
  ],
  'oxylabs' => %w[price_check price_check_complete],
  'sendgrid' => %w[delivery bounce engagement suppression unknown],
  'sftpgo' => %w[recording_uploaded],
  'shipengine' => %w[tracking_update],
  'switchvox' => %w[new_voicemail checked_voicemail incoming_call route_to_extension call_answered call_hangup outgoing_call agent_login agent_logout unknown]
}.freeze
MAX_RETRY_ATTEMPTS =

Maximum retry attempts before moving to exception

5
RETRY_DELAYS =

Retry delay calculation (exponential backoff)

[5.minutes, 15.minutes, 1.hour, 4.hours, 24.hours].freeze

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryString (readonly)

Returns:

  • (String)


78
# File 'app/models/webhook_log.rb', line 78

validates :category, presence: true

#dataHash (readonly)

Returns:

  • (Hash)


80
# File 'app/models/webhook_log.rb', line 80

validates :data, presence: true

#providerString (readonly)

Returns:

  • (String)


76
# File 'app/models/webhook_log.rb', line 76

validates :provider, presence: true

Class Method Details

.awaiting_callbackActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are awaiting callback. Active Record Scope

Returns:

See Also:



130
# File 'app/models/webhook_log.rb', line 130

scope :awaiting_callback, -> { where(state: 'pending') }

.create_pending!(provider:, category:, resource_type:, resource_id:, external_id: nil, data: {}, notes: nil) ⇒ WebhookLog

Class method to create a pending entry when submitting a job
Call this when you submit a job that expects a webhook callback

Parameters:

  • provider (String)

    The webhook provider (e.g., 'assemblyai')

  • category (String)

    The webhook category (e.g., 'transcription_complete')

  • resource_type (String)

    Associated resource type (e.g., 'CallRecord', 'Video')

  • resource_id (Integer)

    Associated resource ID

  • external_id (String, nil) (defaults to: nil)

    Optional external ID from provider (e.g., job_id)

  • data (Hash) (defaults to: {})

    Optional metadata about the submission

  • notes (String, nil) (defaults to: nil)

    Optional notes about the submission

Returns:



248
249
250
251
252
253
254
255
256
257
258
259
# File 'app/models/webhook_log.rb', line 248

def self.create_pending!(provider:, category:, resource_type:, resource_id:, external_id: nil, data: {}, notes: nil)
  create!(
    provider: provider,
    category: category,
    resource_type: resource_type,
    resource_id: resource_id,
    external_id: external_id,
    data: data,
    state: 'pending',
    notes: notes
  )
end

.find_by_external_id(provider, external_id) ⇒ void

This method returns an undefined value.

Find any entry by external_id (for duplicate detection)
Uses the external_id column, not JSON data

Parameters:

  • provider (Object)
  • external_id (Object)


359
360
361
362
363
364
365
# File 'app/models/webhook_log.rb', line 359

def self.find_by_external_id(provider, external_id)
  return nil if external_id.blank?

  where(provider: provider, external_id: external_id)
    .order(created_at: :desc)
    .first
end

.find_pending_for_resource(provider, category, resource_type, resource_id) ⇒ void

This method returns an undefined value.

Find a pending entry for a specific resource

Parameters:

  • provider (Object)
  • category (Object)
  • resource_type (Object)
  • resource_id (Object)


373
374
375
376
377
378
379
380
381
382
383
# File 'app/models/webhook_log.rb', line 373

def self.find_pending_for_resource(provider, category, resource_type, resource_id)
  return nil if resource_type.blank? || resource_id.blank?

  where(
    provider: provider,
    category: category,
    resource_type: resource_type,
    resource_id: resource_id,
    state: 'pending'
  ).order(created_at: :desc).first
end

.for_resourceActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are for resource. Active Record Scope

Returns:

See Also:



132
# File 'app/models/webhook_log.rb', line 132

scope :for_resource, ->(resource) { where(resource_type: resource.class.name, resource_id: resource.id) }

.ingest!(provider:, category:, data:, resource_type: nil, resource_id: nil, external_id: nil, notes: nil) ⇒ WebhookLog

Class method to ingest a webhook callback
Finds existing pending entry or creates a new ready entry
Prevents duplicates by checking external_id

Parameters:

  • provider (String)

    The webhook provider (e.g., 'assemblyai')

  • category (String)

    The webhook category (e.g., 'transcription_complete')

  • resource_type (String, nil) (defaults to: nil)

    Associated resource type

  • resource_id (Integer, nil) (defaults to: nil)

    Associated resource ID

  • data (Hash)

    The webhook payload

  • external_id (Object, nil) (defaults to: nil)
  • notes (Object, nil) (defaults to: nil)

Returns:

  • (WebhookLog)

    The updated or created log entry



272
273
274
275
276
277
278
279
280
281
# File 'app/models/webhook_log.rb', line 272

def self.ingest!(provider:, category:, data:, resource_type: nil, resource_id: nil, external_id: nil, notes: nil)
  log = resolve_ingest(provider:, category:, data:, resource_type:, resource_id:, external_id:, notes:)
  # Confirm the row durably committed before the controller ACKs 200 to the
  # provider. A silent fake-success commit (see Durability) would otherwise
  # drop the webhook with no retry and no trace; raising here makes the
  # controller return 5xx so the provider re-delivers. Verify by natural key
  # when we have an external_id (the high-value providers all send one).
  Durability.confirm_persisted!(self, { provider: provider, external_id: external_id }, context: { category: category }) if external_id.present?
  log
end

.payload_containsActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are payload contains. Active Record Scope

Returns:

See Also:



138
139
140
141
142
143
# File 'app/models/webhook_log.rb', line 138

scope :payload_contains, ->(term) {
  return none if term.blank?

  sanitized = "%#{sanitize_sql_like(term)}%"
  where('data::text ILIKE :term OR response_data::text ILIKE :term', term: sanitized)
}

.providers_for_selectObject

Helper for tom-select dropdowns



449
450
451
# File 'app/models/webhook_log.rb', line 449

def self.providers_for_select
  PROVIDERS.map { |p| [p.titleize, p] }
end

.ransackable_associations(_auth_object = nil) ⇒ void

Parameters:

  • _auth_object (Object) (defaults to: nil)
  • _auth_object (Object) (defaults to: nil)

Returns:

  • (void)
  • (void)


157
158
159
# File 'app/models/webhook_log.rb', line 157

def self.ransackable_associations(_auth_object = nil)
  []
end

.ransackable_attributes(_auth_object = nil) ⇒ void

This method returns an undefined value.

Ransack attributes for search

Parameters:

  • _auth_object (Object) (defaults to: nil)


148
149
150
151
# File 'app/models/webhook_log.rb', line 148

def self.ransackable_attributes(_auth_object = nil)
  %w[id provider category resource_type resource_id external_id state process_attempts
     created_at updated_at processed_at next_attempt notes]
end

.ransackable_scopes(_auth_object = nil) ⇒ void

This method returns an undefined value.

Custom Ransack scopes for advanced searching

Parameters:

  • _auth_object (Object) (defaults to: nil)


164
165
166
# File 'app/models/webhook_log.rb', line 164

def self.ransackable_scopes(_auth_object = nil)
  %i[payload_contains]
end

.recentActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are recent. Active Record Scope

Returns:

See Also:



134
# File 'app/models/webhook_log.rb', line 134

scope :recent, ->(hours = 24) { where(created_at: hours.hours.ago..) }

.requiring_processingActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are requiring processing. Active Record Scope

Returns:

See Also:



116
117
118
119
120
# File 'app/models/webhook_log.rb', line 116

scope :requiring_processing, -> {
  where(state: 'ready')
    .or(where(state: 'retry').where(next_attempt: ..Time.current))
    .order(:created_at)
}

.stale_pendingActiveRecord::Relation<WebhookLog>

A relation of WebhookLogs that are stale pending. Active Record Scope

Returns:

See Also:



123
124
125
126
127
# File 'app/models/webhook_log.rb', line 123

scope :stale_pending, ->(threshold = 1.hour) {
  where(state: 'pending')
    .where(created_at: ..threshold.ago)
    .order(:created_at)
}

.states_for_selectObject

Helper for tom-select dropdowns



444
445
446
# File 'app/models/webhook_log.rb', line 444

def self.states_for_select
  state_machine.states.map { |s| [s.name.to_s.titleize, s.name.to_s] }
end

Instance Method Details

#data_jsonObject

Virtual attribute for editing data as JSON string
Used in the edit form to allow manual payload editing



85
86
87
88
89
# File 'app/models/webhook_log.rb', line 85

def data_json
  JSON.pretty_generate(data) if data.present?
rescue JSON::GeneratorError
  data.to_s
end

#data_json=(json_string) ⇒ void

Parameters:

  • json_string (Object)
  • json_string (Object)

Returns:

  • (void)
  • (void)


95
96
97
98
99
100
101
102
103
# File 'app/models/webhook_log.rb', line 95

def data_json=(json_string)
  return if json_string.blank?

  self.data = JSON.parse(json_string)
rescue JSON::ParserError => e
  @data_json_error = e.message
  # Keep the raw string so we can show an error and preserve user input
  @data_json_raw = json_string
end

#display_nameObject

Display name for logs



459
460
461
# File 'app/models/webhook_log.rb', line 459

def display_name
  "#{provider.titleize} - #{category} (##{id})"
end

#human_state_nameObject

Human readable state name



454
455
456
# File 'app/models/webhook_log.rb', line 454

def human_state_name
  state.to_s.titleize
end

#process!Object

Process this webhook log entry
Delegates to the appropriate processor based on provider/category



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'app/models/webhook_log.rb', line 387

def process!
  return unless can_start_processing?

  start_processing!

  begin
    result = processor_class.call(self)
    self.response_data = result if result.is_a?(Hash)
    self.notes = nil # Clear any previous error notes on success
    complete!
  rescue StandardError => e
    self.notes = "#{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"

    if process_attempts >= MAX_RETRY_ATTEMPTS
      fail!
      ErrorReporting.error(e, webhook_log_id: id, provider: provider, category: category)
    else
      schedule_retry!
      Rails.logger.warn "[WebhookLog] Scheduled retry for #{id}: #{e.message}"
    end
  end
end

#processor_classObject

Get the appropriate processor class for this webhook



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'app/models/webhook_log.rb', line 411

def processor_class
  case provider
  when 'amazon_sp_api'
    WebhookProcessors::AmazonSpApiProcessor
  when 'assemblyai'
    WebhookProcessors::AssemblyaiProcessor
  when 'curri'
    WebhookProcessors::CurriProcessor
  when 'freightquote'
    WebhookProcessors::FreightquoteProcessor
  when 'oxylabs'
    WebhookProcessors::OxylabsProcessor
  when 'sendgrid'
    WebhookProcessors::SendgridProcessor
  when 'sftpgo'
    WebhookProcessors::SftpgoProcessor
  when 'shipengine'
    WebhookProcessors::ShipengineProcessor
  when 'switchvox'
    WebhookProcessors::SwitchvoxProcessor
  else
    raise NotImplementedError, "No processor for provider: #{provider}"
  end
end

#resourceObject

Find the associated resource



437
438
439
440
441
# File 'app/models/webhook_log.rb', line 437

def resource
  return nil if resource_type.blank? || resource_id.blank?

  resource_type.constantize.find_by(id: resource_id)
end