Intégrer MonCashConnect avec Ruby on Rails 7
Backend Rails idiomatique : service PORO Faraday pour appeler /pay-create, controller webhook avec skip_before_action :verify_authenticity_token, et vérification HMAC-SHA256 en temps constant via ActiveSupport::SecurityUtils.secure_compare.
Commencez en Sandbox
Avant d'implémenter le code ci-dessous avec votre clé live (sk_proj_…), testez d'abord en mode Sandbox avec sk_test_proj_…. Le code est identique — vous ne changez que la valeur de la variable d'environnement. Lisez le Guide Sandbox avant de continuer.
Prérequis
- Ruby 3.1+ (testé jusqu'à 3.3)
- Rails 7.1+ (compatible 7.0 et 8.0)
- Un projet MonCashConnect actif — créer un projet
- Webhook accessible en HTTPS (ngrok en dev)
Installation
bundle add faradayopenssl et active_support/security_utils sont déjà inclus dans Rails — pas de gem supplémentaire pour le HMAC.
Stocker les clés dans Rails credentials
EDITOR=vim bin/rails credentials:edit# config/credentials.yml.enc (chiffré, safe à commit)
moncashconnect:
secret_key: sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
webhook_secret: whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxRAILS_MASTER_KEY. Ne committez jamais config/master.key (Rails l'ajoute automatiquement au .gitignore).Service PORO MonCashConnect
Pour garder les controllers minces, encapsulez les appels HTTP dans un service.
# app/services/moncash_connect.rb
require "faraday"
require "json"
class MoncashConnect
API_BASE = "https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1".freeze
class Error < StandardError
attr_reader :status, :body
def initialize(status, body)
@status = status
@body = body
super("MonCashConnect API error (#{status}): #{body}")
end
end
def initialize(secret_key: Rails.application.credentials.dig(:moncashconnect, :secret_key))
@secret_key = secret_key
@conn = Faraday.new(url: API_BASE) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.options.timeout = 10
f.options.open_timeout = 5
end
end
def create_payment(amount:, reference_id:, return_url:, customer_name: nil, customer_email: nil, idempotency_key: nil)
headers = { "Authorization" => "Bearer #{@secret_key}" }
headers["Idempotency-Key"] = idempotency_key if idempotency_key
payload = {
amount: amount,
referenceId: reference_id,
returnUrl: return_url,
customerName: customer_name,
customerEmail: customer_email,
}.compact
res = @conn.post("/pay-create", payload, headers)
raise Error.new(res.status, res.body) if res.status >= 400
res.body # { "paymentUrl" => "...", "reference" => "...", ... }
end
def retrieve(reference)
res = @conn.get("/pay-status", { reference: reference },
{ "Authorization" => "Bearer #{@secret_key}" })
raise Error.new(res.status, res.body) if res.status >= 400
res.body
end
endController paiements
# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
before_action :authenticate_user! # Devise ou équivalent
def create
order = current_user.orders.create!(
amount: params.require(:amount).to_i,
status: "pending",
)
begin
payment = MoncashConnect.new.create_payment(
amount: order.amount,
reference_id: order.id.to_s,
return_url: payment_return_url, # défini par routes
customer_email: current_user.email,
idempotency_key: "order-#{order.id}",
)
rescue MoncashConnect::Error => e
Rails.logger.error("MCC create failed: #{e.message}")
order.update(status: "failed")
redirect_to root_path, alert: "Impossible d'initier le paiement."
return
end
order.update(mcc_reference: payment["reference"])
redirect_to payment["paymentUrl"], allow_other_host: true
end
def return
reference = params[:ref]
return redirect_to root_path, alert: "Référence manquante." if reference.blank?
# SOURCE DE VÉRITÉ = appel serveur, pas les query params
tx = MoncashConnect.new.retrieve(reference)
@order = Order.find_by(mcc_reference: reference)
@status = tx["status"]
# Affiche app/views/payments/return.html.erb
end
endallow_other_host: true est obligatoire sur Rails 7+ pour rediriger vers un domaine externe (MonCash). Sans lui, Rails lève une UnsafeRedirectError.Controller webhook
Le controller webhook doit faire trois choses : sauter la protection CSRF, lire request.raw_post avant tout parsing, et comparer la signature en temps constant avec secure_compare.
# app/controllers/webhooks_controller.rb
class WebhooksController < ActionController::Base
# ActionController::Base (pas ::API) pour utiliser les filtres CSRF,
# qu'on va explicitement skip ici.
skip_before_action :verify_authenticity_token, only: [:moncash]
def moncash
signature = request.headers["X-MCC-Signature"].to_s
timestamp = request.headers["X-MCC-Timestamp"].to_s
raw_body = request.raw_post
secret = Rails.application.credentials.dig(:moncashconnect, :webhook_secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
# Comparaison en temps constant — JAMAIS == ou eql?
unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
return head :unauthorized
end
event = JSON.parse(raw_body)
# Idempotence — voir Order#process_mcc_event pour la logique métier
if ProcessedWebhook.exists?(event_id: event["id"])
return head :ok
end
ProcessedWebhook.create!(event_id: event["id"])
case event["event"]
when "payment.completed"
Order.find_by(mcc_reference: event["reference"])&.update!(status: "paid")
when "payment.failed"
Order.find_by(mcc_reference: event["reference"])&.update!(status: "failed")
end
head :ok
rescue JSON::ParserError
head :bad_request
end
endProcessedWebhook est une migration recommandée pour garantir l'idempotence en multi-instance :# db/migrate/20260520000000_create_processed_webhooks.rb
class CreateProcessedWebhooks < ActiveRecord::Migration[7.1]
def change
create_table :processed_webhooks do |t|
t.string :event_id, null: false
t.timestamps
end
add_index :processed_webhooks, :event_id, unique: true
end
endRoutes
# config/routes.rb
Rails.application.routes.draw do
resources :payments, only: [:create]
get "/payment/return", to: "payments#return", as: :payment_return
post "/webhooks/moncash", to: "webhooks#moncash"
endhttps://votresite.com/webhooks/moncash dans le dashboard MonCashConnect. En dev, utilisez https://<votre-id>.ngrok-free.app/webhooks/moncash.Liste de contrôle avant production
moncashconnect.secret_key et moncashconnect.webhook_secret sont dans config/credentials.yml.enc
RAILS_MASTER_KEY est définie sur le serveur (Heroku, Fly, etc.) — pas master.key versionné
skip_before_action :verify_authenticity_token, only: [:moncash] est présent dans WebhooksController
request.raw_post est lu avant toute autre opération sur le body
ActiveSupport::SecurityUtils.secure_compare est utilisé (pas ==, ni eql?)
Migration ProcessedWebhook créée et UNIQUE INDEX sur event_id
redirect_to vers paymentUrl utilise allow_other_host: true
Idempotency-Key envoyé à create_payment (idempotent par order.id)
Le controller webhook hérite d'ActionController::Base ou ::API (pas un autre parent custom)
force_ssl = true en production (config/environments/production.rb)
Faraday timeout est défini (10 s) pour éviter les goroutines bloquées
FAQ
Pourquoi skip_before_action :verify_authenticity_token ?+
Faraday ou Net::HTTP ?+
Où mettre les clés API ?+
Comment lire le raw body sans qu'ActionController le parse ?+
Compatible Hotwire / Turbo ?+
Comment tester le webhook en dev ?+
Lectures recommandées :