Skip to main content
All webhooks are cryptographically signed using RSA-SHA256. Always verify signatures before processing events to ensure they genuinely came from Ark.

Signature Headers

Each webhook request includes:
HeaderDescription
X-Ark-SignatureBase64-encoded RSA-SHA256 signature of the request body
X-Ark-Signature-KIDKey ID identifying which public key was used

Public Key

Fetch the public key from Ark’s JWKS endpoint:
GET https://mail.arkhq.io/.well-known/jwks.json
Cache the public key to avoid fetching on every webhook. Only refresh if signature verification fails—the key rarely changes.

Verification Implementation

require 'net/http'
require 'json'
require 'openssl'
require 'base64'

class ArkWebhookVerifier
  JWKS_URL = 'https://mail.arkhq.io/.well-known/jwks.json'
  CACHE_TTL = 3600 # 1 hour

  def initialize
    @cache = {}
    @cache_expires_at = nil
  end

  def verify!(request)
    signature = request.headers['X-Ark-Signature']
    key_id = request.headers['X-Ark-Signature-KID']

    raise 'Missing signature headers' if signature.blank? || key_id.blank?

    body = request.body.read
    request.body.rewind

    raise 'Invalid signature' unless valid_signature?(body, signature, key_id)

    JSON.parse(body)
  end

  private

  def valid_signature?(body, signature, key_id)
    public_key = fetch_public_key(key_id)
    return false unless public_key

    decoded_signature = Base64.strict_decode64(signature)
    public_key.verify(OpenSSL::Digest.new('SHA256'), decoded_signature, body)
  rescue OpenSSL::PKey::RSAError, ArgumentError => e
    Rails.logger.error "[Ark Webhook] Verification error: #{e.message}"
    false
  end

  def fetch_public_key(key_id)
    jwks = fetch_jwks
    key_data = jwks['keys']&.find { |k| k['kid'] == key_id }
    return nil unless key_data

    build_rsa_key(key_data)
  rescue StandardError => e
    Rails.logger.error "[Ark Webhook] Failed to fetch public key: #{e.message}"
    nil
  end

  def fetch_jwks
    if @cache['jwks'] && @cache_expires_at && Time.now < @cache_expires_at
      return @cache['jwks']
    end

    uri = URI(JWKS_URL)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.open_timeout = 5
    http.read_timeout = 5

    response = http.get(uri.path)
    jwks = JSON.parse(response.body)

    @cache['jwks'] = jwks
    @cache_expires_at = Time.now + CACHE_TTL

    jwks
  end

  def build_rsa_key(key_data)
    n_bn = OpenSSL::BN.new(Base64.urlsafe_decode64(key_data['n']), 2)
    e_bn = OpenSSL::BN.new(Base64.urlsafe_decode64(key_data['e']), 2)

    # Build RSA public key using ASN1 encoding (works with OpenSSL 3.0+)
    asn1 = OpenSSL::ASN1::Sequence([
      OpenSSL::ASN1::Sequence([
        OpenSSL::ASN1::ObjectId('rsaEncryption'),
        OpenSSL::ASN1::Null.new(nil)
      ]),
      OpenSSL::ASN1::BitString(
        OpenSSL::ASN1::Sequence([
          OpenSSL::ASN1::Integer(n_bn),
          OpenSSL::ASN1::Integer(e_bn)
        ]).to_der
      )
    ])

    OpenSSL::PKey::RSA.new(asn1.to_der)
  end
end

Usage in Controller

class ArkWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    verifier = ArkWebhookVerifier.new
    event = verifier.verify!(request)

    # Idempotency check
    return head :ok if already_processed?(event['uuid'])
    mark_as_processed!(event['uuid'])

    # Process event
    process_event(event)

    head :ok
  rescue => e
    Rails.logger.warn "[Ark Webhook] #{e.message}"
    head :unauthorized
  end

  private

  def already_processed?(uuid)
    Rails.cache.exist?("ark_webhook:#{uuid}")
  end

  def mark_as_processed!(uuid)
    Rails.cache.write("ark_webhook:#{uuid}", true, expires_in: 24.hours)
  end

  def process_event(event)
    case event['event']
    when 'MessageSent'
      # Handle delivery
    when 'MessageBounced', 'MessageDeliveryFailed'
      # Handle bounce
    when 'MessageLoaded'
      # Handle open
    end
  end
end

Best Practices

Never process webhooks without verifying the RSA signature first. This ensures the webhook genuinely came from Ark and hasn’t been tampered with.
Webhooks may be delivered more than once due to retries. Use the uuid field to track processed events and skip duplicates.
Return a 2xx within 5 seconds. Queue events for async processing using background jobs (Sidekiq, etc.) if processing takes longer.
Webhook URLs must use HTTPS. Ark will not deliver to HTTP endpoints.
Set up alerts for webhook delivery failures in your monitoring system to catch configuration issues early.