Ruby on RailsRuby 3.1+ · Rails 7.1+ · Faraday · stdlib OpenSSL

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.

Sandbox

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 faraday

openssl 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
En production, la clé maîtresse vient de la variable d'environnement RAILS_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
end

Controller 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
end
allow_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
end
ProcessedWebhook 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
end

Routes

# 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"
end
Configurez https://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 ?+
Rails attache un token CSRF aux formulaires HTML pour empêcher un site tiers de soumettre des actions au nom de l'utilisateur. Le webhook MonCashConnect arrive d'un serveur, pas d'un navigateur, sans cookie de session. La protection équivalente est la signature HMAC — bien plus forte que CSRF dans ce contexte.
Faraday ou Net::HTTP ?+
Faraday a une API plus moderne et un système de middlewares (retry, logging). Net::HTTP est dans la stdlib. Les deux fonctionnent. Le code Faraday ci-dessous tient en ~15 lignes par méthode.
Où mettre les clés API ?+
Dans config/credentials.yml.enc via bin/rails credentials:edit. Évitez ENV en dev (simple mais facile à committer par erreur). En prod, la master key vient de RAILS_MASTER_KEY ou config/master.key.
Comment lire le raw body sans qu'ActionController le parse ?+
request.raw_post — c'est une string toujours disponible, AVANT et APRÈS le parsing. Vous pouvez l'utiliser pour le HMAC tout en laissant params accessible pour le routing.
Compatible Hotwire / Turbo ?+
Oui. Le bouton de checkout peut être un button_to ou un form classique — la redirection vers paymentUrl quitte de toute façon le navigateur vers le domaine MonCash, donc Turbo Drive ne s'applique pas.
Comment tester le webhook en dev ?+
Exposez votre Rails local avec ngrok (ngrok http 3000), configurez l'URL ngrok dans le dashboard MonCashConnect, et déclenchez un paiement Sandbox. Voir aussi /guides/webhook-testing.