← Semua picks

Bundle Recipe Recommended

Bundle Payment: Stripe + Midtrans + Xendit SaaS Indonesia

Bundle 3 payment provider untuk SaaS Indonesia yang serve customer lokal + global. Saya integrate ke 4 klien SaaS Jakarta 2024-2026. Architecture, cost, dan failover detail.

18 Juni 2026 · 11 menit ·Use case: Multi-payment provider untuk SaaS Indonesia dengan customer global
StripeMidtransXenditWebhook

TL;DR

  • Stack: Stripe (global) + Midtrans (Indonesia) + Xendit (e-wallet Indonesia broader) + middleware webhook normalizer.
  • Cost: 2.5-3.5% per transaction tergantung mix metode + Rp 1500-4000 fixed per provider.
  • Verdict: Recommended untuk SaaS Indonesia 2026 yang serve customer mix lokal + global.

Konteks

Saya integrate bundle 3 payment ini di 4 SaaS klien Jakarta 2024-2026:

  1. SaaS dental Jakarta (2024-now): Stripe (10% transaksi) + Midtrans (75%) + Xendit (15%). ARR Rp 240 juta. Customer 95% Indonesia, 5% expat/global.
  2. AI legal assistant (2025-now): Stripe (30%) + Midtrans (50%) + Xendit (20%). ARR Rp 180 juta. Mix corporate Indonesia + SaaS firm global.
  3. Booking SaaS klinik kecantikan (Q4 2024-now): Midtrans (85%) + Xendit (15%). ARR Rp 120 juta. Skip Stripe karena 100% Indonesia.
  4. Content subscription fotografer Tangerang (Q1 2026-now): Stripe (mobile IAP via RevenueCat handled separately) + Midtrans web. ARR Rp 60 juta.

Total transaction processed bundle: ~Rp 2.4 miliar GMV 24 bulan.

Pricing (Juni 2026)

Stripe

  • Domestic (USD card): 2.9% + $0.30
  • International: 3.9% + $0.30 (extra 1% currency conversion jika applicable)
  • Indonesia accept (sejak Atlas + Local entity 2024): settlement via PT entity
  • Monthly fee: $0 (no monthly minimum)

Midtrans

  • Credit card domestic: 2.5% + Rp 2.500
  • GoPay: 2.0% + Rp 1.500
  • ShopeePay: 2.0% + Rp 1.500
  • Virtual Account: Rp 4.000 flat per transaction
  • QRIS: 0.7% + Rp 700
  • Monthly fee: Rp 0 untuk SMB tier

Xendit

  • E-wallet (OVO, DANA, LinkAja): 2.5% + Rp 1.000
  • Credit card: 2.9% + Rp 2.500
  • Virtual Account: Rp 4.000 flat
  • QRIS: 0.7% + Rp 700
  • Direct debit (BCA, Mandiri): 1.5% + Rp 4.000
  • Monthly fee: Rp 0

Estimated cost untuk SaaS Indonesia 1000 customer (avg Rp 99rb/bulan):

Provider mixTotal fee/bulan
Midtrans only (e-wallet heavy)Rp 2.4 juta
Xendit onlyRp 2.3 juta
Mix Midtrans 70% + Xendit 30%Rp 2.4 juta
Add Stripe untuk 10% global+Rp 800rb

Total bill payment processing 1K customer: Rp 2.4-3.2 juta/bulan.

Architecture

Customer checkout

Frontend (Next.js / Astro)
    ↓ (select payment method)
Payment Provider chooser
    ├── Card global → Stripe Checkout
    ├── E-wallet Indonesia → Midtrans Snap atau Xendit
    └── Transfer / VA → Midtrans atau Xendit

Webhook callback

Webhook Normalizer Middleware (Hono di Cloudflare Workers)
    ↓ (unified event)
Order/Subscription DB (Postgres via Drizzle)

Email confirmation + access provision

Middleware normalize event = key untuk maintain.

Setup recipe (2-3 minggu untuk MVP)

Week 1: Single provider (Midtrans atau Xendit)

Start dengan 1 provider. Tipikal Midtrans (paling popular Indonesia).

Day 1-2: Setup Midtrans

  • Sign up Midtrans dashboard
  • Get sandbox key + production key
  • Setup webhook URL

Day 3-4: Integration

// lib/payment/midtrans.ts
import midtrans from 'midtrans-client';

const snap = new midtrans.Snap({
  isProduction: process.env.NODE_ENV === 'production',
  serverKey: process.env.MIDTRANS_SERVER_KEY!,
});

export async function createMidtransTransaction(params: {
  orderId: string;
  amount: number;
  customer: { name: string; email: string };
}) {
  const parameter = {
    transaction_details: {
      order_id: params.orderId,
      gross_amount: params.amount,
    },
    customer_details: {
      first_name: params.customer.name,
      email: params.customer.email,
    },
    enabled_payments: ['credit_card', 'gopay', 'shopeepay', 'bank_transfer'],
  };

  return await snap.createTransaction(parameter);
}

Day 5: Webhook handler

// app/api/webhook/midtrans/route.ts
import { db } from '@/lib/db';
import { createHash } from 'crypto';

export async function POST(req: Request) {
  const body = await req.json();

  // Verify signature
  const serverKey = process.env.MIDTRANS_SERVER_KEY!;
  const signature = createHash('sha512')
    .update(body.order_id + body.status_code + body.gross_amount + serverKey)
    .digest('hex');

  if (signature !== body.signature_key) {
    return new Response('Invalid signature', { status: 401 });
  }

  // Process based on transaction_status
  if (body.transaction_status === 'settlement' || body.transaction_status === 'capture') {
    await db.update(orders).set({ status: 'paid' }).where(eq(orders.id, body.order_id));
    // Trigger access provision
  }

  return new Response('OK');
}

Week 2: Add Xendit (untuk e-wallet broader coverage)

Day 1-2: Setup Xendit

  • Sign up + get API key
  • Configure payment method (OVO, DANA, LinkAja)

Day 3-4: Integration

// lib/payment/xendit.ts
import { Xendit } from 'xendit-node';

const xendit = new Xendit({ secretKey: process.env.XENDIT_SECRET_KEY! });

export async function createXenditInvoice(params: {
  externalId: string;
  amount: number;
  payerEmail: string;
}) {
  return await xendit.Invoice.createInvoice({
    data: {
      externalId: params.externalId,
      amount: params.amount,
      payerEmail: params.payerEmail,
      currency: 'IDR',
    },
  });
}

Day 5: Webhook + normalizer

// app/api/webhook/xendit/route.ts
export async function POST(req: Request) {
  const body = await req.json();
  const token = req.headers.get('x-callback-token');

  if (token !== process.env.XENDIT_CALLBACK_TOKEN) {
    return new Response('Invalid token', { status: 401 });
  }

  // Normalize ke shape internal
  const event = {
    provider: 'xendit',
    orderId: body.external_id,
    status: body.status === 'PAID' ? 'paid' : 'pending',
    amount: body.amount,
  };

  await processPaymentEvent(event);
  return new Response('OK');
}

Week 3: Add Stripe (untuk global customer)

Day 1-2: Setup Stripe

  • Sign up (atau Atlas jika butuh US entity)
  • Get API key
  • Configure product + price

Day 3-4: Integration

// lib/payment/stripe.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createStripeCheckout(params: {
  customerId: string;
  priceId: string;
  successUrl: string;
  cancelUrl: string;
}) {
  return await stripe.checkout.sessions.create({
    customer: params.customerId,
    line_items: [{ price: params.priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
  });
}

Day 5: Webhook + reconciliation

// app/api/webhook/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')!;
  const body = await req.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    // Normalize + process
  }

  return new Response('OK');
}

Provider selection logic

// lib/payment/selector.ts
type PaymentMethod = 'card_global' | 'card_id' | 'ewallet' | 'va' | 'qris';

export function selectProvider(method: PaymentMethod, country: string) {
  if (country !== 'ID' && method === 'card_global') return 'stripe';
  if (method === 'ewallet') {
    // Xendit lebih broad coverage (OVO/DANA/LinkAja)
    return 'xendit';
  }
  if (method === 'va') {
    // Midtrans + Xendit comparable, pick by current uptime
    return getCurrentBestProvider(['midtrans', 'xendit']);
  }
  return 'midtrans'; // default Indonesia
}

Failover: jika Midtrans down, auto-route ke Xendit (real production saving: 2x outage avoided 2025).

Reconciliation workflow

Daily cron job match payment dari 3 provider ke order:

// cron/reconcile.ts
async function reconcileDaily() {
  const yesterday = subDays(new Date(), 1);

  const midtransTx = await fetchMidtransTransactions(yesterday);
  const xenditTx = await fetchXenditTransactions(yesterday);
  const stripeTx = await fetchStripeTransactions(yesterday);

  const allOrders = await db.select().from(orders).where(gte(orders.createdAt, yesterday));

  for (const order of allOrders) {
    const matchedTx = findMatchingTransaction(order, [...midtransTx, ...xenditTx, ...stripeTx]);
    if (!matchedTx && order.status === 'paid') {
      // Alert: orphaned payment
    }
  }
}

Trade-off

Pro bundle ini

  • Coverage 95%+ payment method Indonesia + global
  • Failover protection (1 provider down, alternative tetap jalan)
  • Lower cost per transaction vs Stripe-only (saving 20-40% untuk customer Indonesia)
  • Better conversion (customer prefer payment method familiar)

Con bundle ini

  • Setup awal 2-3 minggu (vs 1 minggu single provider)
  • Maintenance reconciliation 4-8 jam/bulan
  • Webhook complexity (3 format normalize)
  • Refund flow beda per provider — UI customer service lebih kompleks
  • Vendor lock-in moderate (3 vendor + middleware)

Konteks Indonesia

SaaS dental Jakarta: 1200 active subscriber, ARR Rp 240 juta. Mix: Midtrans 75% (GoPay 40%, VA 35%), Stripe 10% (corporate global), Xendit 15% (OVO/DANA). Bill payment processing Rp 6 juta/bulan (2.5% of GMV).

AI legal assistant: 800 user (300 corporate Indonesia + 100 global SaaS firm + 400 individual). Stripe ~30% revenue dari global, Midtrans 50%, Xendit 20%.

Klinik kecantikan: Skip Stripe karena 100% Indonesia. Midtrans + Xendit cukup. Saving setup cost 1 minggu.

Verdict

Recommended untuk SaaS Indonesia 2026 dengan customer mix lokal + global.

Pakai bundle full (3 provider) jika:

  • Customer global > 10% revenue
  • ARR > Rp 100 juta (overhead setup worth)
  • Reconciliation team capacity 4-8 jam/bulan
  • Critical untuk failover (high-volume)

Pakai Midtrans + Xendit (skip Stripe) jika:

  • Customer 100% Indonesia
  • Focus e-wallet + VA + card lokal
  • SMB stage (< Rp 100 juta ARR)

Pakai single provider (Midtrans atau Xendit) jika:

  • MVP launch (validate first, scale later)
  • Solo dev, prefer simplicity
  • ARR < Rp 50 juta

Skip jika: payment via Apple/Google IAP untuk mobile-only (lihat Bundle Mobile Expo + Supabase + RevenueCat).

Untuk billing implementation: Bundle SaaS billing IDR + Midtrans.

Ditulis oleh Asti Larasati

// Pick Bundle Recipe lain


← Semua picks RSS feed