Signature Headers
Each webhook request includes:| Header | Description |
|---|---|
X-Ark-Signature | Base64-encoded RSA-SHA256 signature of the request body |
X-Ark-Signature-KID | Key ID identifying which public key was used |
Public Key
Fetch the public key from Ark’s JWKS endpoint:Copy
GET https://mail.arkhq.io/.well-known/jwks.json
Cache the public key to avoid fetching on every webhook. Only refresh if verification fails — the key rarely changes.
Verification Implementation
- Python
- Node.js
- Ruby
- Go
Copy
import json
import time
import base64
import requests
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.backends import default_backend
JWKS_URL = "https://mail.arkhq.io/.well-known/jwks.json"
CACHE_TTL = 3600 # 1 hour
MAX_AGE_SECONDS = 300 # 5 minutes
_jwks_cache = {"data": None, "expires_at": 0}
def fetch_jwks():
now = time.time()
if _jwks_cache["data"] and now < _jwks_cache["expires_at"]:
return _jwks_cache["data"]
response = requests.get(JWKS_URL, timeout=5)
jwks = response.json()
_jwks_cache["data"] = jwks
_jwks_cache["expires_at"] = now + CACHE_TTL
return jwks
def get_public_key(key_id):
jwks = fetch_jwks()
key_data = next((k for k in jwks["keys"] if k["kid"] == key_id), None)
if not key_data:
return None
# Decode modulus and exponent
n_bytes = base64.urlsafe_b64decode(key_data["n"] + "==")
e_bytes = base64.urlsafe_b64decode(key_data["e"] + "==")
n = int.from_bytes(n_bytes, "big")
e = int.from_bytes(e_bytes, "big")
return RSAPublicNumbers(e, n).public_key(default_backend())
def verify_webhook(request):
signature = request.headers.get("X-Ark-Signature")
key_id = request.headers.get("X-Ark-Signature-KID")
if not signature or not key_id:
raise ValueError("Missing signature headers")
body = request.get_data()
public_key = get_public_key(key_id)
if not public_key:
raise ValueError(f"Unknown key ID: {key_id}")
decoded_signature = base64.b64decode(signature)
public_key.verify(
decoded_signature,
body,
padding.PKCS1v15(),
hashes.SHA256()
)
return json.loads(body)
# Flask example
@app.post("/webhooks/ark")
def handle_webhook():
try:
payload = verify_webhook(request)
except Exception as e:
return str(e), 401
# Check timestamp to prevent replay attacks
timestamp = payload.get("timestamp", 0)
if abs(time.time() - timestamp) > MAX_AGE_SECONDS:
return "Timestamp too old", 400
# Process the webhook
process_event(payload)
return "OK", 200
Copy
import crypto from 'crypto';
const JWKS_URL = 'https://mail.arkhq.io/.well-known/jwks.json';
const CACHE_TTL = 3600 * 1000; // 1 hour
const MAX_AGE_SECONDS = 300; // 5 minutes
let jwksCache: { data: any; expiresAt: number } = { data: null, expiresAt: 0 };
async function fetchJWKS() {
if (jwksCache.data && Date.now() < jwksCache.expiresAt) {
return jwksCache.data;
}
const response = await fetch(JWKS_URL);
const jwks = await response.json();
jwksCache = { data: jwks, expiresAt: Date.now() + CACHE_TTL };
return jwks;
}
function buildPublicKey(keyData: any): string {
// Convert JWK to PEM format
const key = crypto.createPublicKey({ key: keyData, format: 'jwk' });
return key.export({ type: 'spki', format: 'pem' }) as string;
}
async function getPublicKey(keyId: string): Promise<string | null> {
const jwks = await fetchJWKS();
const keyData = jwks.keys.find((k: any) => k.kid === keyId);
if (!keyData) return null;
return buildPublicKey(keyData);
}
export async function verifyWebhook(req: Request): Promise<any> {
const signature = req.headers['x-ark-signature'] as string;
const keyId = req.headers['x-ark-signature-kid'] as string;
if (!signature || !keyId) {
throw new Error('Missing signature headers');
}
const body = await getRawBody(req);
const publicKey = await getPublicKey(keyId);
if (!publicKey) {
throw new Error(`Unknown key ID: ${keyId}`);
}
const isValid = crypto.verify(
'sha256',
body,
publicKey,
Buffer.from(signature, 'base64')
);
if (!isValid) {
throw new Error('Invalid signature');
}
return JSON.parse(body.toString());
}
// Express example
app.post('/webhooks/ark', async (req, res) => {
try {
const payload = await verifyWebhook(req);
// Check timestamp to prevent replay attacks
const ageSeconds = Math.floor(Date.now() / 1000) - (payload.timestamp || 0);
if (ageSeconds < 0 || ageSeconds > MAX_AGE_SECONDS) {
return res.status(400).send('Timestamp too old');
}
// Process the webhook
processEvent(payload);
res.status(200).send('OK');
} catch (error) {
res.status(401).send(error.message);
}
});
Copy
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
MAX_AGE_SECONDS = 300 # 5 minutes
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)
payload = JSON.parse(body)
# Replay attack prevention
timestamp = payload['timestamp'].to_i
age = Time.now.to_i - timestamp
raise 'Timestamp too old' if age < 0 || age > MAX_AGE_SECONDS
payload
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)
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)
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
# Rails controller
class ArkWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
verifier = ArkWebhookVerifier.new
payload = verifier.verify!(request)
idempotency_key = "#{payload.dig('message', 'token')}_#{payload['status']}"
return head :ok if already_processed?(idempotency_key)
mark_as_processed!(idempotency_key)
process_payload(payload)
head :ok
rescue => e
Rails.logger.warn "[Ark Webhook] #{e.message}"
head :unauthorized
end
end
Copy
package webhook
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"sync"
"time"
)
const (
jwksURL = "https://mail.arkhq.io/.well-known/jwks.json"
cacheTTL = time.Hour
maxAgeSeconds = 300
)
type JWK struct {
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
type JWKS struct {
Keys []JWK `json:"keys"`
}
type Verifier struct {
mu sync.RWMutex
cache *JWKS
expireAt time.Time
}
func NewVerifier() *Verifier {
return &Verifier{}
}
func (v *Verifier) Verify(r *http.Request) (map[string]interface{}, error) {
signature := r.Header.Get("X-Ark-Signature")
keyID := r.Header.Get("X-Ark-Signature-KID")
if signature == "" || keyID == "" {
return nil, fmt.Errorf("missing signature headers")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
pubKey, err := v.getPublicKey(keyID)
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
sigBytes, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return nil, fmt.Errorf("failed to decode signature: %w", err)
}
hash := sha256.Sum256(body)
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sigBytes); err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("failed to parse body: %w", err)
}
// Replay attack prevention
if ts, ok := payload["timestamp"].(float64); ok {
age := time.Now().Unix() - int64(ts)
if age < 0 || age > maxAgeSeconds {
return nil, fmt.Errorf("timestamp too old")
}
}
return payload, nil
}
func (v *Verifier) getPublicKey(keyID string) (*rsa.PublicKey, error) {
jwks, err := v.fetchJWKS()
if err != nil {
return nil, err
}
for _, key := range jwks.Keys {
if key.Kid == keyID {
return buildRSAKey(key)
}
}
return nil, fmt.Errorf("unknown key ID: %s", keyID)
}
func (v *Verifier) fetchJWKS() (*JWKS, error) {
v.mu.RLock()
if v.cache != nil && time.Now().Before(v.expireAt) {
defer v.mu.RUnlock()
return v.cache, nil
}
v.mu.RUnlock()
v.mu.Lock()
defer v.mu.Unlock()
resp, err := http.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwks JWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, err
}
v.cache = &jwks
v.expireAt = time.Now().Add(cacheTTL)
return &jwks, nil
}
func buildRSAKey(jwk JWK) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
}
// Usage in HTTP handler:
// verifier := webhook.NewVerifier()
// payload, err := verifier.Verify(r)
Best Practices
Always verify signatures
Always verify signatures
Never process webhooks without verifying the RSA signature. This ensures the webhook came from Ark and hasn’t been tampered with.
Validate timestamps
Validate timestamps
Reject webhooks with timestamps older than 5 minutes to prevent replay attacks. Combined with signature verification, this ensures webhooks are both authentic and recent.
Handle duplicates with idempotency
Handle duplicates with idempotency
Webhooks may be delivered more than once due to retries. Use the message
token combined with status to track processed events and skip duplicates.Respond quickly
Respond quickly
Return a 2xx within 5 seconds. Queue events for async processing using background jobs (Sidekiq, Celery, Bull, etc.) if processing takes longer.
Use HTTPS
Use HTTPS
Webhook URLs must use HTTPS. Ark will not deliver to HTTP endpoints.
Monitor failures
Monitor failures
Set up alerts for webhook delivery failures in your monitoring system to catch configuration issues early.
