Back to home

How to Integrate Mayar.id Payments Into Your Product

5 min read•

You built a SaaS. People in Indonesia want to use it. Now you need them to pay you every month.

You look at Stripe first, because every tutorial does. Then you remember your customers pay with QRIS, GoPay and bank transfer, not credit cards. So you go looking for a local payment gateway, and you find a dozen of them, each with a different dashboard, API and onboarding process.

This post is the guide I wish I had at that point. It shows you how to take payments in your SaaS with Mayar.id, an Indonesian payment gateway, from creating your account to a customer paying a monthly bill, using the setup I run in production for nakama-cloud.

At the end there's an Agent Skill you can give to Cursor or Claude Code, so your coding agent can build the same thing in your codebase.

Mayar is for Indonesia

Let's be clear about this up front, because it decides whether the rest of this post is for you.

Mayar is an Indonesian payment platform. It's built for Indonesian businesses selling to Indonesian customers:

  • Prices and payments are in Rupiah (IDR).
  • The payment methods are the ones Indonesians use every day: QRIS (which any banking or e-wallet app can scan, including BCA and OVO), bank virtual accounts (BNI, BRI, Mandiri, CIMB, Permata, BJB, BSI), e-wallets (GoPay, DANA, ShopeePay, LinkAja, Jenius), and Alfamart.
  • The dashboard and emails are in Bahasa Indonesia. The checkout page has an English option.
  • Your money lands in your Mayar balance, and you withdraw it to an Indonesian bank account or e-wallet after completing Mayar's account verification (Verifikasi Akun in the dashboard).

So if you're an Indonesian founder, or your customers are mostly in Indonesia, Mayar is a good fit. If your customers are mostly abroad and pay with international cards in USD or EUR, this isn't the tool: look at Stripe, Paddle or Lemon Squeezy instead.

Why Mayar

If you are selling in Indonesia, here's what Mayar gives you:

  • Indonesian payment methods out of the box. QRIS, virtual accounts from the major banks, e-wallets, retail outlets, and cards. Customers pick on Mayar's checkout page; you don't build any of it.
  • A simple API. You create an invoice, send the customer to a link, and Mayar calls your server when they've paid.
  • A sandbox with a "Simulate payment" button. You can test the whole flow without real money.
  • Emails handled for you. Mayar sends the invoice, the payment reminder and the receipt.

What it doesn't do: manage your users, decide what a payment unlocks, or (in the setup below) run your billing calendar. That part lives in your app, and honestly, that's where it should be.

How a payment works

Before any code, here's the whole flow:

1. Customer clicks "Buy" in your app
2. Your server creates an invoice on Mayar     → gets back a payment link
3. Customer is sent to that link and pays       (QRIS, VA, e-wallet…)
4. Mayar calls your server: "payment.received"  → you unlock what they bought
5. Mayar sends the customer back to your app

Remember one rule from this: step 4 unlocks access, not step 5. The customer coming back to your app is just a nice touch. They might close the tab before that. The webhook in step 4 is what you trust.

Pick the right Mayar product

Mayar offers a few ways to charge. For a SaaS you'll look at two:

  • Membership. Mayar runs the subscription for you: it registers the customer on a plan and bills them every month.
  • Invoice. You create a bill with line items, Mayar collects it. Repeating it every month is up to you.

Membership sounds like the obvious choice for a SaaS, and it works if every customer has exactly one plan. But it has two limits you'll hit quickly:

  • One customer email can only be registered once per plan. Trying again returns an error.
  • There's no quantity. You can't sell "3 seats" in one checkout.

So the day a customer wants a second seat, a second workspace, or a bigger package of the same thing, you're stuck. I learned this the hard way and switched to invoices.

My recommendation: use invoices. One invoice can say "3 × Rp 100.000", it goes to the customer's real email, and you can create as many as you like for the same person. The monthly part is a small background job, which this guide covers.

Step 1: Set up your Mayar account

Sandbox first

Mayar has a separate sandbox environment. Build everything there first.

DashboardAPI base URL
Sandboxweb.mayar.clubhttps://api.mayar.club/hl/v2
Productionweb.mayar.idhttps://api.mayar.id/hl/v2

Keep the API key and the base URL from the same environment. A sandbox key against the production URL (or the other way round) just returns errors.

Create an API key

In the dashboard, create an API key with Read & Write access. A read-only key can't create invoices.

Register your webhook

This is where Mayar sends payment.received. Under Integrasi → Webhook, set:

https://your-app.com/api/mayar-webhook?token=SOME_LONG_RANDOM_SECRET

Mayar doesn't sign its requests, so that token is how your server knows the request really came from Mayar. Generate something long and random (openssl rand -hex 32).

Click TEST URL after saving. The history table under the form should show a row with status SUCCESS. Do this in production too when you go live. Mine was set correctly in sandbox and empty in production, and nothing warned me.

Environment variables

MAYAR_API_KEY=...
MAYAR_API_BASE=https://api.mayar.club/hl/v2   # https://api.mayar.id/hl/v2 in production
MAYAR_WEBHOOK_TOKEN=...                        # the same secret as ?token=
APP_URL=https://your-app.com

Step 2: Store what customers buy

Your database needs to know two things: what each customer has, and which payments are in progress. I'll call the thing you sell a seat; rename it to whatever fits your product (workspace, license, credit pack).

-- What the customer has.
CREATE TABLE seats (
  id                 TEXT PRIMARY KEY,
  owner_id           TEXT NOT NULL,
  status             TEXT NOT NULL DEFAULT 'pending',  -- pending | active | inactive
  amount_idr         INTEGER NOT NULL,
  current_period_end TIMESTAMP,                        -- paid until
  created_at         TIMESTAMP NOT NULL DEFAULT now()
);
 
-- One row per Mayar invoice: a first purchase, or a monthly renewal.
CREATE TABLE billing_orders (
  id                   TEXT PRIMARY KEY,
  owner_id             TEXT NOT NULL,
  kind                 TEXT NOT NULL,            -- purchase | renewal
  quantity             INTEGER NOT NULL,
  amount_idr           INTEGER NOT NULL,
  seat_ids             JSON NOT NULL,            -- which seats this invoice pays for
  customer_email       TEXT NOT NULL,
  customer_name        TEXT NOT NULL,
  customer_mobile      TEXT NOT NULL,
  mayar_invoice_id     TEXT,
  mayar_transaction_id TEXT,
  invoice_url          TEXT,
  invoice_expires_at   TIMESTAMP,
  status               TEXT NOT NULL DEFAULT 'pending',  -- pending | paid | expired | failed
  paid_at              TIMESTAMP
);
 
CREATE INDEX billing_orders_tx ON billing_orders (mayar_transaction_id);

Why two tables? A seat is something the customer has. An order is one attempt to pay for some seats. When a customer buys 3 seats, you create 3 pending seats and 1 order. A month later, one renewal order extends those same 3 seats.

Step 3: Talk to the Mayar API

You only need two calls: create an invoice, and read an invoice. Here's a small client in TypeScript:

const BASE = (process.env.MAYAR_API_BASE ?? 'https://api.mayar.club/hl/v2').replace(/\/$/, '')
 
async function mayarFetch<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.MAYAR_API_KEY}`,
      'Content-Type': 'application/json',
    },
  })
  const body = await res.json()
  if ((body.statusCode ?? res.status) >= 400) {
    // Validation errors put the readable message in data, e.g. "The 'mobile' field is required."
    const detail = Array.isArray(body.data) ? body.data.map((d: { message: string }) => d.message).join(', ') : ''
    throw new Error(detail || body.messages || 'Mayar request failed')
  }
  return body.data as T
}
 
export async function createInvoice(input: {
  name: string
  email: string
  mobile: string
  quantity: number
  rateIdr: number
  itemDescription: string
  expiredAt: Date
  redirectUrl: string
  orderId: string
}) {
  const data = await mayarFetch<{ id: string; transactionId: string; link: string }>(
    '/invoices/create',
    {
      method: 'POST',
      body: JSON.stringify({
        name: input.name,
        email: input.email,
        mobile: input.mobile,
        description: input.itemDescription,
        expiredAt: input.expiredAt.toISOString(),
        redirectUrl: input.redirectUrl,
        items: [{ quantity: input.quantity, rate: input.rateIdr, description: input.itemDescription }],
        extraData: { orderId: input.orderId },
      }),
    },
  )
  return { invoiceId: data.id, transactionId: data.transactionId, link: data.link }
}
 
export function getInvoice(invoiceId: string) {
  return mayarFetch<{ status: string }>(`/invoices/${invoiceId}`)
}
 
export const isPaid = (status?: string) =>
  ['paid', 'success'].includes((status ?? '').toLowerCase())

A few things worth knowing about /invoices/create:

  • The path is plural: /invoices/create. /invoice/create returns a plain 404.
  • mobile is required and must be at least 10 characters. If your sign-up doesn't collect phone numbers, ask for one at checkout.
  • redirectUrl is where Mayar sends the customer after its "Transaction Success" page.
  • extraData comes back to you in the webhook. Put your own order id there.
  • The response gives you id (the invoice), transactionId (the payment), and link (the page to send your customer to). Save all three.

Step 4: The checkout

When a customer clicks "Buy", your server does this:

const PRICE_IDR = 100_000
 
export async function createCheckout(db: Db, user: User, quantity: number, mobile: string) {
  // 1. Write it down before calling Mayar, so the webhook has something to find.
  const order = await db.transaction(async (tx) => {
    const seats = await insertPendingSeats(tx, user.id, quantity, PRICE_IDR)
    return insertOrder(tx, {
      ownerId: user.id,
      kind: 'purchase',
      quantity,
      amountIdr: quantity * PRICE_IDR,
      seatIds: seats.map((s) => s.id),
      customer: { email: user.email, name: user.name, mobile },
    })
  })
 
  // 2. One invoice for everything they're buying.
  try {
    const invoice = await createInvoice({
      name: user.name,
      email: user.email,
      mobile,
      quantity,
      rateIdr: PRICE_IDR,
      itemDescription: 'Seat (1 month)',
      expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // payable for 24 hours
      redirectUrl: `${process.env.APP_URL}/dashboard?billing=success`,
      orderId: order.id,
    })
    await saveInvoiceOnOrder(db, order.id, invoice)
    return { checkoutUrl: invoice.link }
  } catch (error) {
    // Mayar said no: mark the order failed so you don't keep pending seats forever.
    await markOrderFailed(db, order.id)
    throw error
  }
}

Your frontend sends the user to checkoutUrl. Mayar's page shows your line item ("Seat (1 month) × 3"), the total, and all the payment options. After paying, the customer lands on /dashboard?billing=success, where you can show "Payment received, your seats will be ready in a moment."

Step 5: Handle the webhook

When the payment goes through, Mayar POSTs something like this to your webhook URL:

{
  "event": "payment.received",
  "data": {
    "transactionId": "8abf3c04-0bf9-485a-a4b8-77395c7f9f68",
    "status": "SUCCESS",
    "productType": "invoice",
    "productId": "9d48c533-efab-42a2-898e-0e17f595e040",
    "amount": 100000,
    "customerEmail": "customer@example.com",
    "extraData": { "orderId": "78c93a35-fea1-4350-b12b-bc0294a30c98" }
  }
}

transactionId is the same one you got back when you created the invoice, so finding the order is one lookup. Use status: "SUCCESS" as the signal that the payment went through. (There's also a transactionStatus field that says "created" even on a successful payment; ignore it.)

export async function POST(request: Request) {
  // 1. Is this really Mayar?
  const token = new URL(request.url).searchParams.get('token')
  if (token !== process.env.MAYAR_WEBHOOK_TOKEN) {
    return new Response('Unauthorized', { status: 401 })
  }
 
  const payload = await request.json().catch(() => null)
  if (payload?.event === 'payment.received' && isPaid(payload.data?.status)) {
    // 2. Which order is this?
    const order = await findOrderByTransactionId(db, payload.data.transactionId)
    if (order) {
      // 3. Ask Mayar directly before unlocking anything.
      const invoice = await getInvoice(order.mayarInvoiceId)
      if (isPaid(invoice.status)) await applyPaidOrder(db, order.id)
    }
  }
  return new Response('ok')
}

Step 3 is the one people skip. Anyone who gets hold of your webhook URL could send "SUCCESS" for free. Re-reading the invoice from Mayar means a fake request just does nothing.

Unlock exactly once

Mayar retries webhooks that fail, and you'll add a backup check in the next step, so the same payment can reach you more than once. Make sure it only counts once:

export async function applyPaidOrder(db: Db, orderId: string, now = new Date()) {
  // Only the first call flips pending → paid. Every later call matches no rows and stops here.
  const [order] = await db.update(orders)
    .set({ status: 'paid', paidAt: now })
    .where(and(eq(orders.id, orderId), inArray(orders.status, ['pending', 'expired'])))
    .returning()
  if (!order) return
 
  for (const seat of await getSeats(db, order.seatIds)) {
    // Paying early adds a month on top; paying late starts a fresh month from today.
    const from = seat.currentPeriodEnd && seat.currentPeriodEnd > now ? seat.currentPeriodEnd : now
    await db.update(seats)
      .set({ status: 'active', currentPeriodEnd: addDays(from, 30) })
      .where(eq(seats.id, seat.id))
  }
}

That's a working checkout. Customers can buy, pay, and get access.

Step 6: Monthly renewals

With invoices, nobody bills the customer next month unless you do. The good news is it's one function you run every 10 minutes or so (a cron job, a worker, whatever your stack has). Run it on one server only, so two copies don't send two invoices.

export async function billingSweep(db: Db, now = new Date()) {
  // 1. Backup for missed webhooks: ask Mayar about recent pending orders.
  for (const order of await pendingOrdersSince(db, hoursAgo(48))) {
    const invoice = await getInvoice(order.mayarInvoiceId).catch(() => null)
    if (invoice && isPaid(invoice.status)) await applyPaidOrder(db, order.id, now)
  }
 
  // 2. Invoices nobody paid in time: release their pending seats.
  await expireOverdueOrders(db, now)
 
  // 3. Seats whose paid month is over: switch them off.
  await expireLapsedSeats(db, now)
 
  // 4. Seats ending within 7 days: one renewal invoice per customer.
  for (const [ownerId, dueSeats] of await seatsDueForRenewal(db, now, days(7))) {
    const customer = await latestCustomerDetails(db, ownerId)   // email, name and phone from their last order
    const order = await insertOrder(db, { ownerId, kind: 'renewal', seats: dueSeats, customer })
    try {
      const invoice = await createInvoice({
        ...customer,
        quantity: dueSeats.length,
        rateIdr: dueSeats[0].amountIdr,
        itemDescription: 'Seat (1 month)',
        expiredAt: addDays(earliestEnd(dueSeats), 3),   // a few days of grace
        redirectUrl: `${process.env.APP_URL}/dashboard?billing=success`,
        orderId: order.id,
      })
      await saveInvoiceOnOrder(db, order.id, invoice)
    } catch {
      await markOrderFailed(db, order.id)   // the next run will try again
    }
  }
}

Mayar emails each renewal invoice to the customer. Show it in your app too: a banner like "Renew 2 seats for Rp 200.000 by Oct 26 · Pay now", linked to the invoice, gets paid much more reliably than an email.

Keep the order of the steps. Settle payments first, so a customer who paid a minute before their seat expired doesn't get switched off.

Step 7: Test it before real money

The sandbox checkout page has a Simulate payment button (pick QRIS to see it). To get Mayar's webhook to your laptop, use a Cloudflare quick tunnel:

cloudflared tunnel --url http://localhost:3000 --http-host-header localhost:3000

(The --http-host-header flag matters if you use Vite, which otherwise blocks the tunnel's hostname.) Point the sandbox webhook at the tunnel URL, then:

  1. Buy 2 seats in your app. Mayar's page should show quantity 2 and Rp 200.000.
  2. Click Simulate payment. You should land back on your dashboard.
  3. Check the order is paid and both seats are active for 30 days.
  4. Send the same webhook again. Nothing should change.
  5. Send a fake "SUCCESS" for an invoice you didn't pay. Nothing should change.
  6. Move a seat's end date to 6 days from now and run the sweep. A renewal invoice should arrive in your inbox.
  7. Move it to yesterday and run the sweep. The seat should switch off.

Going live checklist

  • Production API key (Read & Write) and https://api.mayar.id/hl/v2 everywhere your code calls Mayar, including the background job.
  • Webhook URL set in web.mayar.id, and TEST URL shows SUCCESS.
  • Your background job logs a loud warning at startup if MAYAR_API_KEY is missing. Otherwise renewals silently never happen.
  • Customers' real email addresses go to Mayar, never a modified version. That's where invoices and receipts land.
  • One real purchase of your cheapest item, end to end. You can refund it from the Mayar dashboard.

Agent Skill (copy this)

If you use a coding agent, the skill is also hosted as a file at ahmadrosid.com/blog/integrating-mayar-id-payments/SKILL.md. Download it into your project:

# Claude Code
mkdir -p .claude/skills/mayar-id-payments
curl -fsSL https://ahmadrosid.com/blog/integrating-mayar-id-payments/SKILL.md -o .claude/skills/mayar-id-payments/SKILL.md
 
# Cursor
mkdir -p .cursor/skills/mayar-id-payments
curl -fsSL https://ahmadrosid.com/blog/integrating-mayar-id-payments/SKILL.md -o .cursor/skills/mayar-id-payments/SKILL.md

Then ask your agent: "Add Mayar billing using the mayar-id-payments skill." You can also paste the URL straight into a chat and tell the agent to follow it.

Here's the full skill if you'd rather read it first:

---
name: mayar-id-payments
description: "Add Mayar.id (Indonesian payment gateway) payments to a SaaS: invoice checkout for any quantity, payment.received webhook, and self-managed monthly renewals in IDR. Use when the user mentions Mayar, mayar.id, mayar.club, Indonesian payments, QRIS, virtual account or e-wallet billing, or selling seats/slots/plans monthly in Rupiah."
---
 
# Mayar.id payments for a SaaS
 
Mayar is an Indonesian payment gateway: prices in Rupiah, QRIS, bank virtual accounts and e-wallets. Full guide: https://ahmadrosid.com/blog/integrating-mayar-id-payments
 
## When to use
 
- Monthly paid access in IDR (seats, workspaces, plans, credit packs) through Mayar.
- Wiring Mayar's `payment.received` webhook into what a customer can use.
 
## When not to use
 
- One-off sales with nothing to unlock later: a Mayar payment link is enough.
 
## Non-negotiables
 
1. Use `POST /invoices/create` (plural) with `items[{quantity, rate, description}]`. Avoid Mayar Membership when a customer may buy more than one: it allows one registration per email per tier and has no quantity.
2. Send the customer's real email, unmodified.
3. Insert pending seats and a `billing_orders` row before calling Mayar. If Mayar errors, mark the order failed and its seats inactive.
4. Save `id` (invoice), `transactionId` and `link` from the response. The webhook's `data.transactionId` equals that `transactionId`.
5. Unlock only after `payment.received` with `data.status: "SUCCESS"` AND `GET /invoices/{invoiceId}` reporting `paid`. Never on the redirect back.
6. Apply an order exactly once with a conditional `pending|expired → paid` update.
7. Check the webhook's `?token=` shared secret; Mayar does not sign requests.
8. `mobile` is required (at least 10 characters). Ask at checkout only if the account has no phone; store it on the order for renewals.
9. Set `redirectUrl` to the app's dashboard with a "payment received" message, and `extraData.orderId` to the local order id.
10. Run one background sweep every ~10 minutes on a single instance: poll pending orders from the last 48 h, expire overdue orders, expire lapsed seats, then send one renewal invoice per customer for seats ending within 7 days.
 
## Environment
 
| Variable | Purpose |
|---|---|
| `MAYAR_API_KEY` | Read & Write key; needed by the web app and the background job |
| `MAYAR_API_BASE` | `https://api.mayar.club/hl/v2` (sandbox) or `https://api.mayar.id/hl/v2` (production) |
| `MAYAR_WEBHOOK_TOKEN` | Shared secret in the webhook URL's `?token=` |
| `APP_URL` | Base for `redirectUrl` |
 
## Build order
 
1. Schema: `seats` (`pending|active|inactive`, `current_period_end`) and `billing_orders` (`kind`, `quantity`, `seat_ids`, customer email/name/mobile, Mayar invoice and transaction ids, `invoice_url`, `invoice_expires_at`, `status`, `paid_at`).
2. Client: `mayarFetch` (Bearer auth, unwrap `data`, surface `data[].message` on validation errors), `createInvoice`, `getInvoice`.
3. Checkout: quantity picker with a live total, phone field when needed, redirect to `link`.
4. Webhook route: token check → find order by transaction id → `getInvoice` → apply once → 200.
5. Background sweep with the four steps above, settling payments before expiring anything.
6. Dashboard banner for a pending renewal, linking to `invoice_url`.
7. Tests: N seats in one order, Mayar error releases seats, duplicate webhook, fake unpaid webhook, renewal paid early and late, one renewal per customer, expiry.
 
## API shapes
 
```http
POST {MAYAR_API_BASE}/invoices/create
Authorization: Bearer {MAYAR_API_KEY}
 
{
  "name": "...", "email": "...", "mobile": "081234567890",
  "description": "...", "expiredAt": "2026-10-19T04:22:45.000Z",
  "redirectUrl": "https://app.example/dashboard?billing=success",
  "items": [{ "quantity": 3, "rate": 100000, "description": "Seat (1 month)" }],
  "extraData": { "orderId": "..." }
}
→ data: { id, transactionId, link, expiredAt, extraData }
 
GET {MAYAR_API_BASE}/invoices/{id}
→ data: { status: "unpaid" | "paid", transactionId, ... }
```
 
Webhook `payment.received` for an invoice: `data.transactionId` (same as create), `data.productId` (invoice id), `data.productType: "invoice"`, `data.status: "SUCCESS"`, `data.extraData`.
 
## Output expectations
 
1. Propose a file map for this repo first (client, checkout, webhook route, schema/migration, background job, UI).
2. Use the repo's existing stack and keep Mayar code in one billing module.
3. Finish with the env vars to set for each process and the exact webhook URL to register.

Wrapping up

Taking payments in Indonesia doesn't have to mean a month of integration work. With Mayar, the split is simple: Mayar collects the money and tells you about it, and your app decides what that money buys and for how long.

Start in the sandbox, get one simulated payment to unlock a seat, then add the monthly sweep. Once that works, you have billing.

Continue Exploring