All webhooks are cryptographically signed using RSA-SHA256. Always verify signatures before processing events to ensure they genuinely came from Ark.
Each webhook request includes:
Header Description 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.
Handle duplicates with idempotency
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.