MRRX
Documentation

Framework Examples

Drop-in integration code for the most common stacks. Cancel sessions, webhook handlers, and reusable client wrappers — ready to copy.

3 frameworksWebhooks includedTypeScript ready
Next.js
App Router
TypeScript
Express
Node.js
JavaScript
Ruby on Rails
6 and up
Ruby
Before you start
You will need an MRRX API key, your webhook signing secret, and a connected Stripe account. If you have not set those up yet, follow the getting started guide first. For full endpoint details, the API reference covers everything.
Framework

Next.js (App Router)

A complete integration for Next.js 13+ using the App Router. No SDK required — the native fetch API is enough.

Prerequisites

Next.js 13 or later with the App Router
Stripe subscription billing already set up
MRRX account with a connected Stripe account

Environment Variables

MRRX_API_KEY=mrrx_live_xxxxxxxxxxxxxxxxxxxxx
MRRX_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_APP_URL=https://yourapp.com

Basic Integration

Create the API route
This server route looks up the user's Stripe IDs, calls MRRX, and returns the hosted cancellation URL.
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth'; // Your auth solution

export async function POST(req: NextRequest) {
  try {
    // Get authenticated user
    const session = await auth();
    if (!session?.user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // Get user's Stripe IDs from your database
    const user = await db.user.findUnique({
      where: { id: session.user.id },
      select: {
        stripeCustomerId: true,
        stripeSubscriptionId: true,
      },
    });

    if (!user?.stripeCustomerId || !user?.stripeSubscriptionId) {
      return NextResponse.json(
        { error: 'No active subscription' },
        { status: 400 }
      );
    }

    // Create MRRX session
    const response = await fetch('https://mrrx.app/api/v1/sessions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.MRRX_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        customer_id: user.stripeCustomerId,
        subscription_id: user.stripeSubscriptionId,
        return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account?canceled=true`,
        metadata: {
          user_id: session.user.id,
        },
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      console.error('MRRX error:', error);
      return NextResponse.json(
        { error: error.error?.message || 'Failed to create session' },
        { status: response.status }
      );
    }

    const data = await response.json();
    return NextResponse.json({ url: data.url });
  } catch (error) {
    console.error('Cancel subscription error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}
Build the cancel button
A small client component that hits the route and redirects the subscriber to the MRRX hosted flow.
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';

export function CancelSubscriptionButton() {
  const [loading, setLoading] = useState(false);

  const handleCancel = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/cancel-subscription', {
        method: 'POST',
      });

      const data = await response.json();

      if (data.url) {
        // Redirect to MRRX cancellation flow
        window.location.href = data.url;
      } else {
        throw new Error(data.error || 'Failed to start cancellation');
      }
    } catch (error) {
      console.error('Error:', error);
      alert('Failed to start cancellation. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button
      variant="outline"
      onClick={handleCancel}
      disabled={loading}
    >
      {loading ? 'Loading...' : 'Cancel Subscription'}
    </Button>
  );
}
Place it on your account page
Drop the button anywhere subscribers manage their plan.
import { CancelSubscriptionButton } from '@/components/cancel-subscription-button';

export default function AccountPage() {
  return (
    <div className="container py-10">
      <h1 className="text-2xl font-bold mb-6">Account Settings</h1>

      {/* ... other account settings ... */}

      <section className="mt-10 pt-6 border-t">
        <h2 className="text-lg font-semibold mb-4">Subscription</h2>
        <p className="text-muted-foreground mb-4">
          Need to cancel? We'd hate to see you go.
        </p>
        <CancelSubscriptionButton />
      </section>
    </div>
  );
}

Webhook Handler

Verify the signature, then route the event to the appropriate handler. The verification helper parses the t=<timestamp>,v1=<sig> format and uses constant-time comparison.

import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';

function verifyWebhook(
  payload: string,
  signatureHeader: string,
  secret: string
): boolean {
  const parts = signatureHeader.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const signature = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!timestamp || !signature) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

export async function POST(req: NextRequest) {
  try {
    const payload = await req.text();
    const signature = req.headers.get('x-mrrx-signature');

    if (!signature || !process.env.MRRX_WEBHOOK_SECRET) {
      return NextResponse.json({ error: 'Missing signature' }, { status: 401 });
    }

    if (!verifyWebhook(payload, signature, process.env.MRRX_WEBHOOK_SECRET)) {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
    }

    const event = JSON.parse(payload);
    console.log('MRRX webhook received:', event.type);

    switch (event.type) {
      case 'subscription.paused':
        await handleSubscriptionPaused(event.data);
        break;
      case 'subscription.discount_applied':
        await handleDiscountApplied(event.data);
        break;
      case 'subscription.downgraded':
        await handleSubscriptionDowngraded(event.data);
        break;
      case 'subscription.canceled':
        await handleSubscriptionCanceled(event.data);
        break;
      case 'session.completed':
        await handleSessionCompleted(event.data);
        break;
      default:
        console.log('Unhandled event type:', event.type);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    return NextResponse.json({ error: 'Webhook failed' }, { status: 500 });
  }
}

// Event handlers
async function handleSubscriptionPaused(data: {
  customer_id: string;
  subscription_id: string;
  resume_date: string;
}) {
  await db.user.update({
    where: { stripeCustomerId: data.customer_id },
    data: {
      subscriptionStatus: 'paused',
      subscriptionResumeDate: new Date(data.resume_date),
    },
  });
}

async function handleDiscountApplied(data: {
  customer_id: string;
  discount: { type: string; amount: number; duration_months: number };
}) {
  await db.user.update({
    where: { stripeCustomerId: data.customer_id },
    data: {
      hasActiveDiscount: true,
      discountExpiresAt: new Date(
        Date.now() + data.discount.duration_months * 30 * 24 * 60 * 60 * 1000
      ),
    },
  });
}

async function handleSubscriptionDowngraded(data: {
  customer_id: string;
  new_price_id: string;
}) {
  const plan = await getPlanByPriceId(data.new_price_id);
  await db.user.update({
    where: { stripeCustomerId: data.customer_id },
    data: { plan: plan.name },
  });
}

async function handleSubscriptionCanceled(data: {
  customer_id: string;
  cancel_at_period_end: boolean;
}) {
  await db.user.update({
    where: { stripeCustomerId: data.customer_id },
    data: {
      subscriptionStatus: data.cancel_at_period_end ? 'canceling' : 'canceled',
    },
  });
}

async function handleSessionCompleted(data: {
  session_id: string;
  action: string;
  survey_response?: { reason: string; feedback?: string };
}) {
  await db.churnEvent.create({
    data: {
      sessionId: data.session_id,
      action: data.action,
      reason: data.survey_response?.reason,
      feedback: data.survey_response?.feedback,
    },
  });
}

Server Actions Alternative

Prefer Server Actions over a separate API route? The same flow collapses into one server function and a form-button client component.

'use server';

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function createCancelSession() {
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: {
      stripeCustomerId: true,
      stripeSubscriptionId: true,
    },
  });

  if (!user?.stripeSubscriptionId) {
    throw new Error('No active subscription');
  }

  const response = await fetch('https://mrrx.app/api/v1/sessions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.MRRX_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      customer_id: user.stripeCustomerId,
      subscription_id: user.stripeSubscriptionId,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
    }),
  });

  if (!response.ok) {
    throw new Error('Failed to create cancellation session');
  }

  const data = await response.json();
  redirect(data.url);
}
'use client';

import { createCancelSession } from '@/app/actions/subscription';
import { Button } from '@/components/ui/button';

export function CancelButton() {
  return (
    <form action={createCancelSession}>
      <Button type="submit" variant="outline">
        Cancel Subscription
      </Button>
    </form>
  );
}

Reusable Client Wrapper

A typed error class plus a thin wrapper around fetch. Useful when calling MRRX from multiple places.

export class MrrxError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number
  ) {
    super(message);
    this.name = 'MrrxError';
  }
}

export async function createMrrxSession(params: {
  customerId: string;
  subscriptionId: string;
  returnUrl?: string;
  metadata?: Record<string, string>;
}) {
  const response = await fetch('https://mrrx.app/api/v1/sessions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.MRRX_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      customer_id: params.customerId,
      subscription_id: params.subscriptionId,
      return_url: params.returnUrl,
      metadata: params.metadata,
    }),
  });

  const data = await response.json();

  if (!response.ok) {
    throw new MrrxError(
      data.error?.message || 'Unknown error',
      data.error?.code || 'unknown',
      response.status
    );
  }

  return data;
}

TypeScript Types

These types are framework-agnostic — reuse them in any TypeScript project (Express, Fastify, etc.).

export interface MrrxSession {
  id: string;
  token: string;
  url: string;
  expires_at: string;
  status: MrrxSessionStatus;
}

export type MrrxSessionStatus =
  | 'created'
  | 'viewed'
  | 'offer_shown'
  | 'action_selected'
  | 'survey_completed'
  | 'executing'
  | 'completed'
  | 'failed'
  | 'expired';

export interface MrrxWebhookEvent {
  id: string;
  type: string;
  created_at: string;
  data: Record<string, unknown>;
}

export type MrrxAction = 'pause' | 'discount' | 'downgrade' | 'cancel';

export type MrrxCancelReason =
  | 'too_expensive'
  | 'not_using_enough'
  | 'missing_features'
  | 'switching_competitor'
  | 'technical_issues'
  | 'business_closing'
  | 'other';
Framework

Express.js

A standard Express setup with subscription routes, webhook handling, and a reusable client class. Plain JavaScript — the same patterns work in any Node.js app.

Prerequisites

Express.js application
Stripe subscription billing already set up
MRRX account with a connected Stripe account

Installation

npm install express dotenv

Environment Variables

MRRX_API_KEY=mrrx_live_xxxxxxxxxxxxxxxxxxxxx
MRRX_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
APP_URL=https://yourapp.com

Basic Integration

Create the app entry point
Note the conditional middleware: webhook routes need the raw body, so they bypass express.json().
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');

const app = express();

// JSON parser for everything except webhooks (which need raw body)
app.use((req, res, next) => {
  if (req.path === '/webhooks/mrrx') {
    next();
  } else {
    express.json()(req, res, next);
  }
});

// Routes
app.use('/api/subscription', require('./routes/subscription'));
app.use('/webhooks', require('./routes/webhooks'));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Build the subscription routes
A POST endpoint to create a session and a GET endpoint to fetch its status.
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../middleware/auth');
const { getUserById } = require('../models/user');

// Create cancellation session
router.post('/cancel', requireAuth, async (req, res) => {
  try {
    const user = await getUserById(req.user.id);

    if (!user.stripeCustomerId || !user.stripeSubscriptionId) {
      return res.status(400).json({ error: 'No active subscription' });
    }

    const response = await fetch('https://mrrx.app/api/v1/sessions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.MRRX_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        customer_id: user.stripeCustomerId,
        subscription_id: user.stripeSubscriptionId,
        return_url: `${process.env.APP_URL}/account?subscription=updated`,
        metadata: {
          user_id: user.id,
          email: user.email,
        },
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      console.error('MRRX error:', error);
      return res.status(response.status).json({
        error: error.error?.message || 'Failed to create session',
      });
    }

    const data = await response.json();
    res.json({ url: data.url });
  } catch (error) {
    console.error('Cancel subscription error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// Get session status
router.get('/cancel-status/:sessionId', requireAuth, async (req, res) => {
  try {
    const response = await fetch(
      `https://mrrx.app/api/v1/sessions/${req.params.sessionId}`,
      {
        headers: {
          'Authorization': `Bearer ${process.env.MRRX_API_KEY}`,
        },
      }
    );

    if (!response.ok) {
      return res.status(response.status).json({ error: 'Session not found' });
    }

    const data = await response.json();
    res.json(data);
  } catch (error) {
    console.error('Get session error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
Wire up the webhook handler
The verifier parses t=<timestamp>,v1=<sig> and uses constant-time comparison.
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { updateUserSubscription } = require('../models/user');

function verifyMrrxSignature(payload, signatureHeader, secret) {
  const parts = signatureHeader.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const signature = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!timestamp || !signature) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

router.post('/mrrx', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const payload = req.body.toString();
    const signature = req.headers['x-mrrx-signature'];

    if (!signature || !process.env.MRRX_WEBHOOK_SECRET) {
      console.error('Missing signature or secret');
      return res.status(401).json({ error: 'Unauthorized' });
    }

    if (!verifyMrrxSignature(payload, signature, process.env.MRRX_WEBHOOK_SECRET)) {
      console.error('Invalid signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(payload);
    console.log('MRRX webhook received:', event.type, event.id);

    switch (event.type) {
      case 'subscription.paused':
        await handlePaused(event.data);
        break;
      case 'subscription.discount_applied':
        await handleDiscount(event.data);
        break;
      case 'subscription.downgraded':
        await handleDowngrade(event.data);
        break;
      case 'subscription.canceled':
        await handleCanceled(event.data);
        break;
      case 'session.completed':
        await handleSessionCompleted(event.data);
        break;
      default:
        console.log('Unhandled event:', event.type);
    }

    res.json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Webhook processing failed' });
  }
});

// Event handlers
async function handlePaused(data) {
  await updateUserSubscription(data.customer_id, {
    status: 'paused',
    resumeDate: new Date(data.resume_date),
  });
}

async function handleDiscount(data) {
  await updateUserSubscription(data.customer_id, {
    hasDiscount: true,
    discountPercent: data.discount.amount,
    discountEndsAt: new Date(
      Date.now() + data.discount.duration_months * 30 * 24 * 60 * 60 * 1000
    ),
  });
}

async function handleDowngrade(data) {
  await updateUserSubscription(data.customer_id, {
    priceId: data.new_price_id,
  });
}

async function handleCanceled(data) {
  await updateUserSubscription(data.customer_id, {
    status: data.cancel_at_period_end ? 'canceling' : 'canceled',
  });
}

async function handleSessionCompleted(data) {
  console.log('Session completed:', {
    sessionId: data.session_id,
    action: data.action,
    reason: data.survey_response?.reason,
  });
}

module.exports = router;

Reusable Client

A small class wrapping fetch with built-in error handling and signature verification.

class MrrxClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.baseUrl = 'https://mrrx.app/api/v1';
  }

  async createSession({ customerId, subscriptionId, returnUrl, metadata }) {
    const response = await fetch(`${this.baseUrl}/sessions`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        customer_id: customerId,
        subscription_id: subscriptionId,
        return_url: returnUrl,
        metadata,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.error?.message || 'MRRX API error');
    }

    return response.json();
  }

  async getSession(sessionId) {
    const response = await fetch(`${this.baseUrl}/sessions/${sessionId}`, {
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.error?.message || 'MRRX API error');
    }

    return response.json();
  }

  verifyWebhookSignature(payload, signatureHeader, secret) {
    const crypto = require('crypto');
    const parts = signatureHeader.split(',');
    const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
    const signature = parts.find(p => p.startsWith('v1='))?.slice(3);

    if (!timestamp || !signature) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(payload, 'utf8')
      .digest('hex');

    try {
      return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expected)
      );
    } catch {
      return false;
    }
  }
}

module.exports = MrrxClient;
const MrrxClient = require('./lib/mrrx');
const mrrx = new MrrxClient(process.env.MRRX_API_KEY);

const session = await mrrx.createSession({
  customerId: 'cus_xxx',
  subscriptionId: 'sub_xxx',
  returnUrl: 'https://yourapp.com/account',
});

console.log('Redirect to:', session.url);

Error Handler Middleware

Drop-in error middleware that surfaces MRRX errors with their proper status code.

function errorHandler(err, req, res, next) {
  console.error('Error:', err);

  // MRRX-specific errors
  if (err.name === 'MrrxError') {
    return res.status(err.status || 500).json({
      error: err.message,
      code: err.code,
    });
  }

  // Generic errors
  res.status(500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message,
  });
}

module.exports = errorHandler;
Framework

Ruby on Rails

A Rails 6+ integration using a service object, a controller pair, and encrypted credentials. Net::HTTP only — no extra gem required.

Prerequisites

Ruby on Rails 6 or later
Stripe subscription billing already set up
MRRX account with a connected Stripe account

Credentials Setup

Use Rails encrypted credentials so secrets never touch your environment files.

rails credentials:edit
mrrx:
  api_key: mrrx_live_xxxxxxxxxxxxxxxxxxxxx
  webhook_secret: whsec_xxxxxxxxxxxxxxxxxxxxx

Basic Integration

Create the MRRX service
A single service object encapsulates session creation, retrieval, and webhook verification. Net::HTTP keeps the dependency surface small.
require 'net/http'
require 'json'
require 'openssl'

class MrrxService
  BASE_URL = 'https://mrrx.app/api/v1'.freeze

  class Error < StandardError
    attr_reader :code, :status

    def initialize(message, code: nil, status: nil)
      @code = code
      @status = status
      super(message)
    end
  end

  def initialize
    @api_key = Rails.application.credentials.dig(:mrrx, :api_key) ||
               ENV['MRRX_API_KEY']

    raise 'MRRX API key not configured' unless @api_key
  end

  # Create a cancellation session
  def create_session(customer_id:, subscription_id:, return_url: nil, metadata: {})
    payload = {
      customer_id: customer_id,
      subscription_id: subscription_id,
      return_url: return_url,
      metadata: metadata
    }.compact

    post('/sessions', payload)
  end

  # Get session status
  def get_session(session_id)
    get("/sessions/#{session_id}")
  end

  # Verify webhook signature: t=<timestamp>,v1=<hex signature>
  def self.verify_webhook(payload, signature_header, secret = nil)
    secret ||= Rails.application.credentials.dig(:mrrx, :webhook_secret) ||
               ENV['MRRX_WEBHOOK_SECRET']

    return false unless signature_header && secret

    parts = signature_header.split(',')
    timestamp = parts.find { |p| p.start_with?('t=') }&.sub('t=', '')
    signature = parts.find { |p| p.start_with?('v1=') }&.sub('v1=', '')

    return false unless timestamp && signature

    expected = OpenSSL::HMAC.hexdigest('sha256', secret, payload)
    ActiveSupport::SecurityUtils.secure_compare(signature, expected)
  end

  private

  def get(path)
    uri = URI("#{BASE_URL}#{path}")
    request = Net::HTTP::Get.new(uri)
    execute_request(uri, request)
  end

  def post(path, body)
    uri = URI("#{BASE_URL}#{path}")
    request = Net::HTTP::Post.new(uri)
    request.body = body.to_json
    request['Content-Type'] = 'application/json'
    execute_request(uri, request)
  end

  def execute_request(uri, request)
    request['Authorization'] = "Bearer #{@api_key}"

    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
      http.request(request)
    end

    body = JSON.parse(response.body)

    unless response.is_a?(Net::HTTPSuccess)
      error = body['error'] || {}
      raise Error.new(
        error['message'] || 'Unknown error',
        code: error['code'],
        status: response.code.to_i
      )
    end

    body.deep_symbolize_keys
  end
end
Add the subscriptions controller
A POST action to create the session, plus a GET action to look up its status.
class SubscriptionsController < ApplicationController
  before_action :authenticate_user!

  # POST /subscriptions/cancel
  def cancel
    unless current_user.stripe_customer_id && current_user.stripe_subscription_id
      render json: { error: 'No active subscription' }, status: :bad_request
      return
    end

    mrrx = MrrxService.new

    session = mrrx.create_session(
      customer_id: current_user.stripe_customer_id,
      subscription_id: current_user.stripe_subscription_id,
      return_url: account_url(subscription: 'updated'),
      metadata: {
        user_id: current_user.id.to_s,
        email: current_user.email
      }
    )

    render json: { url: session[:url] }

  rescue MrrxService::Error => e
    Rails.logger.error("MRRX error: #{e.message} (#{e.code})")
    render json: { error: e.message }, status: e.status || 500
  rescue => e
    Rails.logger.error("Subscription cancel error: #{e.message}")
    render json: { error: 'Failed to start cancellation' }, status: :internal_server_error
  end

  # GET /subscriptions/cancel_status/:session_id
  def cancel_status
    mrrx = MrrxService.new
    session = mrrx.get_session(params[:session_id])
    render json: session

  rescue MrrxService::Error => e
    render json: { error: e.message }, status: e.status || 404
  end
end
Add the webhook controller
Skip CSRF and authentication, verify the signature, then dispatch on event type.
module Webhooks
  class MrrxController < ApplicationController
    skip_before_action :verify_authenticity_token
    skip_before_action :authenticate_user!

    # POST /webhooks/mrrx
    def create
      payload = request.raw_post
      signature = request.headers['X-MRRX-Signature']

      unless MrrxService.verify_webhook(payload, signature)
        Rails.logger.warn('MRRX webhook: Invalid signature')
        render json: { error: 'Invalid signature' }, status: :unauthorized
        return
      end

      event = JSON.parse(payload).deep_symbolize_keys
      Rails.logger.info("MRRX webhook received: #{event[:type]} (#{event[:id]})")

      handle_event(event)

      render json: { received: true }

    rescue JSON::ParserError => e
      Rails.logger.error("MRRX webhook parse error: #{e.message}")
      render json: { error: 'Invalid JSON' }, status: :bad_request
    rescue => e
      Rails.logger.error("MRRX webhook error: #{e.message}")
      render json: { error: 'Webhook processing failed' }, status: :internal_server_error
    end

    private

    def handle_event(event)
      case event[:type]
      when 'subscription.paused'
        handle_paused(event[:data])
      when 'subscription.discount_applied'
        handle_discount(event[:data])
      when 'subscription.downgraded'
        handle_downgrade(event[:data])
      when 'subscription.canceled'
        handle_canceled(event[:data])
      when 'session.completed'
        handle_session_completed(event[:data])
      else
        Rails.logger.info("Unhandled MRRX event: #{event[:type]}")
      end
    end

    def handle_paused(data)
      user = User.find_by(stripe_customer_id: data[:customer_id])
      return unless user

      user.update!(
        subscription_status: 'paused',
        subscription_resume_date: Time.parse(data[:resume_date])
      )
    end

    def handle_discount(data)
      user = User.find_by(stripe_customer_id: data[:customer_id])
      return unless user

      discount = data[:discount]
      expires_at = Time.current + discount[:duration_months].months

      user.update!(
        has_discount: true,
        discount_percent: discount[:amount],
        discount_expires_at: expires_at
      )
    end

    def handle_downgrade(data)
      user = User.find_by(stripe_customer_id: data[:customer_id])
      return unless user

      new_plan = Plan.find_by(stripe_price_id: data[:new_price_id])
      user.update!(plan: new_plan) if new_plan
    end

    def handle_canceled(data)
      user = User.find_by(stripe_customer_id: data[:customer_id])
      return unless user

      status = data[:cancel_at_period_end] ? 'canceling' : 'canceled'
      user.update!(subscription_status: status)
    end

    def handle_session_completed(data)
      ChurnEvent.create!(
        session_id: data[:session_id],
        action: data[:action],
        reason: data.dig(:survey_response, :reason),
        feedback: data.dig(:survey_response, :feedback)
      )
    end
  end
end
Add the routes
Two subscription routes plus a namespaced webhook route.
Rails.application.routes.draw do
  # Subscription management
  post 'subscriptions/cancel', to: 'subscriptions#cancel'
  get 'subscriptions/cancel_status/:session_id', to: 'subscriptions#cancel_status'

  # Webhooks
  namespace :webhooks do
    post 'mrrx', to: 'mrrx#create'
  end
end

View Integration (Stimulus)

A small Stimulus controller fetches the cancellation URL and redirects.

<div data-controller="subscription">
  <h2>Subscription</h2>

  <% if current_user.subscribed? %>
    <p>Current plan: <%= current_user.plan.name %></p>

    <button
      data-action="click->subscription#cancel"
      data-subscription-target="cancelButton"
      class="btn btn-outline-danger"
    >
      Cancel Subscription
    </button>
  <% end %>
</div>
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["cancelButton"]

  async cancel() {
    const button = this.cancelButtonTarget
    button.disabled = true
    button.textContent = "Loading..."

    try {
      const response = await fetch("/subscriptions/cancel", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": document.querySelector("[name='csrf-token']").content
        }
      })

      const data = await response.json()

      if (data.url) {
        window.location.href = data.url
      } else {
        throw new Error(data.error || "Failed to start cancellation")
      }
    } catch (error) {
      alert("Error: " + error.message)
      button.disabled = false
      button.textContent = "Cancel Subscription"
    }
  }
}

Background Job Alternative

Process webhook events asynchronously to keep response times under 200 ms. Verify and parse synchronously, then queue the rest.

class ProcessMrrxWebhookJob < ApplicationJob
  queue_as :default

  def perform(event)
    case event['type']
    when 'subscription.paused'
      handle_paused(event['data'])
    when 'subscription.discount_applied'
      handle_discount(event['data'])
    when 'subscription.downgraded'
      handle_downgrade(event['data'])
    when 'subscription.canceled'
      handle_canceled(event['data'])
    when 'session.completed'
      handle_session_completed(event['data'])
    end
  end

  private

  def handle_paused(data)
    user = User.find_by(stripe_customer_id: data['customer_id'])
    return unless user

    user.update!(
      subscription_status: 'paused',
      subscription_resume_date: Time.parse(data['resume_date'])
    )
  end

  # ... other handlers
end
def create
  # ... signature verification ...

  event = JSON.parse(payload)

  # Queue for async processing
  ProcessMrrxWebhookJob.perform_later(event)

  # Respond immediately
  render json: { received: true }
end
Don't see your framework?
Every example here is plain HTTP — the patterns translate directly to Django, Laravel, Go, Phoenix, or anything else that can call a REST endpoint and verify an HMAC signature. Start with the API reference for the wire-level details, and reach out at hello@mrrx.app if you want help porting one of these to your stack.

Ready to ship?

Walk through the full setup in twelve minutes, or jump straight to the wire-level docs.