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.