Framework Examples
Drop-in integration code for the most common stacks. Cancel sessions, webhook handlers, and reusable client wrappers — ready to copy.
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
Environment Variables
MRRX_API_KEY=mrrx_live_xxxxxxxxxxxxxxxxxxxxx MRRX_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx NEXT_PUBLIC_APP_URL=https://yourapp.com
Basic Integration
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 }
);
}
}'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>
);
}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';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
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
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}`);
});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;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;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
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
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
endclass 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
endmodule 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
endRails.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
endView 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
enddef create
# ... signature verification ...
event = JSON.parse(payload)
# Queue for async processing
ProcessMrrxWebhookJob.perform_later(event)
# Respond immediately
render json: { received: true }
end