Back to home

How to Integrate Mayar.id Payments Into Your Product

5 min read

If you sell something in Indonesia and want QRIS, bank transfer, and e-wallets without becoming a payment company, Mayar.id is one of the cleaner options. This post is about wiring it into your product: pending entitlement, Membership API, redirect to checkout, webhook as the source of truth.

The patterns come from a real SaaS I ship (nakama-cloud). The domain names below stay generic on purpose. Swap "subscription" or "seat" for whatever you bill.

At the end there is a full Agent Skill you can drop into Cursor (or any agent that reads SKILL.md) so the agent can implement this without reinventing the flow.

What Mayar is good for

Mayar handles Indonesian payment rails. For product billing you usually pick one of two shapes:

  • Payment link / one-off invoice. Good for a single purchase: ebook, workshop ticket, setup fee.
  • Membership (SaaS). Good for recurring access: seats, plans, monthly credits.

This guide focuses on Membership. That is the path when a paid customer should stay entitled until they expire or cancel, and when renewals arrive as another payment.received webhook.

Official docs: docs.mayar.id. Webhooks: docs.mayar.id/integration/webhook.

Mental model: three parts

Your app UI  →  your backend  →  Mayar checkout page
                     ↑
              webhook (paid / expired / cancelled)
  1. Mayar dashboard. Product, tier/price, API key, webhook URL.
  2. Your backend. Create a pending local record, call Mayar to register a member and create an invoice, redirect the user to the bill URL.
  3. Webhooks. Activate, renew, or revoke entitlement. Do not unlock access just because the browser came back from Mayar.

If you only remember one rule: the browser redirect is UX. The webhook is truth.

Dashboard and environment setup

Sandbox vs production

EnvironmentAPI baseNotes
Sandboxhttps://api.mayar.club/hl/v2Local and staging
Productionhttps://api.mayar.id/hl/v2Live money

Older webhook registration endpoints use /hl/v1. Membership calls in this guide use /hl/v2.

Create the product

  1. In Mayar, create a Membership (SaaS) product.
  2. Add a tier with the price and period you sell (for example IDR 50,000 / month).
  3. Copy the product id and tier id.
  4. Create an API key with read/write access.

Webhook URL

Register something like:

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

Mayar POSTs JSON events to that URL. You verify the token query param yourself. Mayar does not sign the body the way Stripe does, so the shared secret on the URL matters.

Env vars

MAYAR_API_KEY=...
MAYAR_API_BASE=https://api.mayar.club/hl/v2   # or api.mayar.id in prod
MAYAR_PRODUCT_ID=...
MAYAR_TIER_ID=...
MAYAR_WEBHOOK_TOKEN=...   # same value as ?token=

Map Mayar objects to your domain

MayarYour app (examples)
MemberCustomer on a plan, seat holder, licensed account
Transaction / invoiceCheckout attempt, order, payment
expiredAt / period endWhen access should stop if not renewed
payment.receivedActivate or renew entitlement
membership.memberExpired / memberUnsubscribedRevoke entitlement

Before you call Mayar, insert a pending row in your database. Webhooks arrive later with ids that may not match what invoice create returned. A pending row gives you something to attach to by transaction id, member id, or customer email.

Checkout flow

Happy path:

  1. Authenticated user clicks Buy.
  2. You insert a pending entitlement (status = pending).
  3. You call Mayar POST /memberships/members/create.
  4. You call Mayar POST /memberships/members/{memberId}/invoice/create.
  5. You store memberId and transactionId on the pending row.
  6. You redirect the browser to membershipBillUrl.
  7. User pays on Mayar.
  8. Webhook payment.received flips the row to active.

Thin API client

const MAYAR_BASE = (process.env.MAYAR_API_BASE ?? 'https://api.mayar.club/hl/v2').replace(/\/$/, '')
 
type MayarEnvelope<T> = {
  statusCode?: number
  message?: string
  messages?: string | string[]
  data?: T
}
 
async function mayarFetch<T>(path: string, init?: RequestInit, attempt = 0): Promise<T> {
  const res = await fetch(`${MAYAR_BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.MAYAR_API_KEY}`,
      'Content-Type': 'application/json',
      ...init?.headers,
    },
  })
 
  if (res.status === 429 && attempt < 1) {
    const retryAfter = Number(res.headers.get('Retry-After') ?? '1')
    await new Promise((r) => setTimeout(r, retryAfter * 1000))
    return mayarFetch<T>(path, init, attempt + 1)
  }
 
  const body = (await res.json()) as MayarEnvelope<T>
  const statusCode = body.statusCode ?? res.status
  if (statusCode >= 400 || !res.ok) {
    const message =
      body.message ??
      (Array.isArray(body.messages) ? body.messages.join(', ') : body.messages) ??
      `Mayar API error (${statusCode})`
    throw new Error(message)
  }
 
  if (body.data !== undefined && body.data !== null) return body.data
  throw new Error('Mayar returned an empty response')
}
 
export async function registerMember(input: {
  name: string
  email: string
  mobile?: string
  productId: string
  membershipTierId: string
  membershipMonthlyPeriod: 1 | 3 | 6 | 12
}) {
  const customerInfo: Record<string, string> = {
    name: input.name,
    email: input.email,
  }
  if (input.mobile) customerInfo.mobile = input.mobile
 
  const data = await mayarFetch<Record<string, unknown>>('/memberships/members/create', {
    method: 'POST',
    body: JSON.stringify({
      productId: input.productId,
      membershipTierId: input.membershipTierId,
      membershipMonthlyPeriod: input.membershipMonthlyPeriod,
      customerInfo,
    }),
  })
 
  // Response shapes vary; pull member id from common locations.
  const memberId =
    (data.membershipCustomer as { memberId?: string; id?: string } | undefined)?.memberId ??
    (data.membershipCustomer as { id?: string } | undefined)?.id ??
    (typeof data.memberId === 'string' ? data.memberId : undefined) ??
    (typeof data.id === 'string' ? data.id : undefined)
 
  if (!memberId) throw new Error('Mayar did not return a member id')
  return { memberId }
}
 
export async function createMemberInvoice(memberId: string, productId: string) {
  const data = await mayarFetch<{
    transactionId?: string
    id?: string
    membershipBillUrl?: string
    link?: string
  }>(`/memberships/members/${memberId}/invoice/create`, {
    method: 'POST',
    body: JSON.stringify({ productId }),
  })
 
  const transactionId = data.transactionId ?? data.id
  const membershipBillUrl = data.membershipBillUrl ?? data.link
  if (!transactionId || !membershipBillUrl) {
    throw new Error('Mayar did not return checkout details')
  }
  return { transactionId, membershipBillUrl }
}
 
export async function getInvoice(invoiceOrTransactionId: string) {
  return mayarFetch<{ status?: string; transactionStatus?: string }>(
    `/invoices/${invoiceOrTransactionId}`,
  )
}

Checkout service shape

export async function createCheckout(db: Db, input: {
  ownerId: string
  email: string
  name: string
  mobile?: string
}) {
  const entitlementId = crypto.randomUUID()
  // See "Gotchas" below if one user can buy multiple entitlements.
  const mayarEmail = uniqueMayarEmail(input.email, entitlementId)
 
  const pending = await insertPendingEntitlement(db, {
    id: entitlementId,
    ownerId: input.ownerId,
    email: mayarEmail,
    status: 'pending',
  })
 
  try {
    const { memberId } = await registerMember({
      name: input.name,
      email: mayarEmail,
      mobile: input.mobile,
      productId: process.env.MAYAR_PRODUCT_ID!,
      membershipTierId: process.env.MAYAR_TIER_ID!,
      membershipMonthlyPeriod: 1,
    })
 
    const { transactionId, membershipBillUrl } = await createMemberInvoice(
      memberId,
      process.env.MAYAR_PRODUCT_ID!,
    )
 
    await attachMayarIds(db, pending.id, { memberId, transactionId })
    return { checkoutUrl: membershipBillUrl, entitlementId, transactionId }
  } catch (error) {
    await markEntitlementFailed(db, pending.id)
    throw error
  }
}

Frontend stays dumb:

const { checkoutUrl } = await createCheckout(...)
window.location.href = checkoutUrl

Webhook handling

Events that matter for most products:

EventWhat you do
payment.receivedIf paid, activate or renew entitlement
membership.memberExpiredRevoke access
membership.memberUnsubscribedRevoke access
payment.reminderOptional nudge email; usually ignore for entitlement

Endpoint sketch

function verifyWebhookToken(request: Request) {
  const expected = process.env.MAYAR_WEBHOOK_TOKEN
  if (!expected) return process.env.NODE_ENV !== 'production'
  const url = new URL(request.url)
  return url.searchParams.get('token') === expected
}
 
export async function POST(request: Request) {
  if (!verifyWebhookToken(request)) {
    return new Response('Unauthorized', { status: 401 })
  }
 
  let payload: unknown
  try {
    payload = await request.json()
  } catch {
    return new Response('ok', { status: 200 })
  }
 
  await processMayarWebhook(db, payload)
  return new Response('ok', { status: 200 })
}

Return 200 once you have accepted the payload. If your handler throws after Mayar already delivered, Mayar may retry. Store events by transactionId so retries stay idempotent.

Process paid / expired

type MayarWebhookPayload = {
  event?: string
  data?: {
    id?: string
    transactionId?: string
    transactionStatus?: string
    status?: string | boolean
    memberId?: string
    customerEmail?: string
    expiredAt?: string
    updatedAt?: string
  }
}
 
function isPaidStatus(status: string | boolean | undefined) {
  if (status === true) return true
  if (typeof status !== 'string') return false
  const normalized = status.toLowerCase()
  return normalized === 'paid' || normalized === 'success'
}
 
export async function processMayarWebhook(db: Db, payload: MayarWebhookPayload) {
  const event = payload.event
  const transactionId = payload.data?.transactionId ?? payload.data?.id ?? null
  const memberId = payload.data?.memberId
 
  if (transactionId) {
    await recordWebhookEvent(db, { transactionId, event: event ?? 'unknown', payload })
  }
 
  if (event === 'payment.received' && transactionId && (
    isPaidStatus(payload.data?.transactionStatus) || isPaidStatus(payload.data?.status)
  )) {
    // Optional but recommended: confirm with Mayar before unlocking.
    const invoice = await getInvoice(transactionId).catch(() => null)
    if (invoice && !isPaidStatus(invoice.transactionStatus) && !isPaidStatus(invoice.status)) {
      return
    }
 
    const periodEnd = payload.data?.expiredAt
      ? new Date(payload.data.expiredAt)
      : null
 
    let activated = await activateByTransactionId(db, transactionId, periodEnd)
    if (!activated && payload.data?.customerEmail) {
      // Invoice create and payment.received can return different ids.
      activated = await activatePendingByEmail(db, payload.data.customerEmail, {
        transactionId,
        periodEnd,
      })
    }
 
    if (activated?.alreadyActive) {
      await renewByMemberId(db, activated.memberId ?? memberId, periodEnd)
    }
    return
  }
 
  if (
    (event === 'membership.memberExpired' || event === 'membership.memberUnsubscribed') &&
    memberId
  ) {
    await deactivateByMemberId(db, memberId)
  }
}

Gotchas

One member per email per tier. Mayar rejects a second membership for the same email on the same tier. If one user can buy multiple entitlements (extra seats, extra servers), do not reuse their login email. Plus-addressing works:

function uniqueMayarEmail(userEmail: string, entitlementId: string) {
  const at = userEmail.indexOf('@')
  if (at === -1) throw new Error('Invalid email')
  const local = userEmail.slice(0, at)
  const domain = userEmail.slice(at + 1)
  return `${local}+${entitlementId}@${domain}`
}

Mail still lands in the same inbox. Mayar sees a unique member.

Transaction ids can disagree. The id from invoice/create is not always the id on payment.received. Match by transaction id first, then fall back to pending row by customer email or member id.

Do not activate on redirect. Users can close the tab. Activate when the webhook says paid (and optionally after getInvoice confirms it).

Sandbox vs prod base URL. Wrong host with a live key wastes an afternoon. Put MAYAR_API_BASE in env and fail loud if the key is missing.

Minimal schema

You do not need this exact schema. You need these ideas:

CREATE TYPE entitlement_status AS ENUM ('pending', 'active', 'inactive');
 
CREATE TABLE entitlements (
  id UUID PRIMARY KEY,
  owner_id TEXT NOT NULL,
  mayar_member_id TEXT,
  mayar_transaction_id TEXT,
  mayar_member_email TEXT NOT NULL,
  status entitlement_status NOT NULL DEFAULT 'pending',
  amount_idr INTEGER NOT NULL,
  current_period_end TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE mayar_webhook_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  transaction_id TEXT NOT NULL,
  event TEXT NOT NULL,
  payload JSONB,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE UNIQUE INDEX mayar_webhook_events_transaction_id_unique
  ON mayar_webhook_events (transaction_id);

Adjust uniqueness if you need multiple webhook rows per transaction (store event type in the unique key instead).

Agent Skill (copy this)

Save as .cursor/skills/mayar-id-payments/SKILL.md in a project, or under your personal skills folder. Then ask your agent: "Integrate Mayar Membership checkout using the mayar-id-payments skill."

---
name: mayar-id-payments
description: Integrate Mayar.id Membership (SaaS) checkout and webhooks into a product. Use when the user mentions Mayar, mayar.id, Indonesian payments, QRIS billing, Membership API, mayar webhook, or wants recurring IDR checkout with payment.received activation.
---
 
# Mayar.id Membership payments
 
## When to use
 
- User wants Mayar.id (or mayar.club sandbox) Membership checkout in their app.
- User needs QRIS / bank transfer / e-wallet recurring or seat-style billing in IDR.
- User asks to wire `payment.received`, member expire, or unsubscribe webhooks.
 
## When not to use
 
- One-off payment links with no entitlement state (point them at Mayar payment links docs instead).
- Non-Indonesian PSP work (Stripe, Midtrans-only, etc.) unless they explicitly chose Mayar.
 
## Non-negotiables
 
1. Create a **pending** local entitlement **before** calling Mayar.
2. Unlock access only from webhooks (optionally after re-fetching the invoice). Never from browser return URL alone.
3. Mayar allows **one member per email per tier**. If one user can buy multiple entitlements, use plus-addressing: `local+{entitlementId}@domain`.
4. Treat invoice/create transaction ids and webhook transaction ids as possibly different. Fallback match by member id or customer email.
5. Verify webhook requests with a shared secret (`?token=` on the webhook URL or equivalent).
6. Persist webhook events for idempotency. Return HTTP 200 after safe accept.
7. On Mayar API failure after pending insert, mark the local row failed (do not leave forever-pending orphans without a status).
 
## Environment
 
| Variable | Purpose |
|---|---|
| `MAYAR_API_KEY` | Bearer token |
| `MAYAR_API_BASE` | `https://api.mayar.club/hl/v2` (sandbox) or `https://api.mayar.id/hl/v2` (prod) |
| `MAYAR_PRODUCT_ID` | Membership product id |
| `MAYAR_TIER_ID` | Membership tier id |
| `MAYAR_WEBHOOK_TOKEN` | Shared secret for `?token=` |
 
Docs: https://docs.mayar.id. Webhooks: https://docs.mayar.id/integration/webhook
 
## Implementation checklist
 
Work in this order. Do not skip ahead to UI polish.
 
1. **Dashboard.** Confirm Membership product + tier exist. Confirm webhook URL registered.
2. **Schema.** Entitlement table with `pending|active|inactive`, Mayar member/transaction ids, member email, `current_period_end`. Webhook event log keyed for idempotency.
3. **Client.** Thin `mayarFetch` with Bearer auth, JSON envelope unwrap (`data`), one 429 retry. Helpers: `registerMember`, `createMemberInvoice`, `getInvoice`.
4. **Checkout.** Auth user → insert pending → `POST /memberships/members/create``POST /memberships/members/{id}/invoice/create` → save ids → return `membershipBillUrl` → frontend redirects.
5. **Webhook route.** Verify token. Parse JSON. Handle:
   - `payment.received` + paid/success → activate or renew (verify via `GET /invoices/{id}` when possible)
   - `membership.memberExpired` / `membership.memberUnsubscribed` → deactivate and revoke product access
6. **Product glue.** Map active entitlements to whatever the product sells (plan flag, seat count, feature gate). Keep that mapping in one place.
7. **Tests.** Unit-test paid detection, activate/renew/deactivate, plus-address email helper, unauthorized webhook, and "invoice not paid" early return.
 
## API shapes (Membership)
 
Register member:
 
```http
POST {MAYAR_API_BASE}/memberships/members/create
Authorization: Bearer {MAYAR_API_KEY}
Content-Type: application/json
 
{
  "productId": "...",
  "membershipTierId": "...",
  "membershipMonthlyPeriod": 1,
  "customerInfo": { "name": "...", "email": "...", "mobile": "..." }
}
```
 
Create invoice:
 
```http
POST {MAYAR_API_BASE}/memberships/members/{memberId}/invoice/create
{ "productId": "..." }
```
 
Response fields to read (with fallbacks): `transactionId` or `id`, `membershipBillUrl` or `link`.
 
## Agent output expectations
 
1. First propose a short file map for **this** repo (client, checkout service, webhook route, schema/migration, UI trigger). Ask only if product entitlement meaning is ambiguous.
2. Implement against the repo's existing stack (framework, ORM, auth). Do not invent a second app.
3. Keep Mayar-specific code in a billing/payments module. Domain entitlement names should match the product language.
4. After implementation, list env vars to set and the exact webhook URL to register in Mayar.

Install the skill (3 steps)

  1. Create .cursor/skills/mayar-id-payments/
  2. Paste the block above into SKILL.md (without the outer blog fence if your editor already treats it as a file)
  3. Tell your agent to use mayar-id-payments when adding billing

Test plan before going live

  1. Sandbox keys + api.mayar.club.
  2. Start checkout, pay with sandbox methods Mayar provides.
  3. Confirm webhook hits your tunnel or staging URL and flips pending → active.
  4. Buy a second entitlement as the same user (plus-address path).
  5. Simulate or wait for expire/unsubscribe and confirm access revokes.
  6. Force a Mayar API error (bad product id) and confirm the pending row becomes failed, not stuck forever.

Apply this to your product tomorrow

  1. Create Membership product + tier in Mayar (sandbox first).
  2. Add the five env vars.
  3. Add pending entitlement + webhook event tables.
  4. Paste the client + checkout + webhook pattern into your stack.
  5. Drop the Agent Skill into the repo and let the agent fill framework-specific glue.
  6. Register https://your-domain/api/mayar-webhook?token=... and run one real sandbox payment end to end.

That is the whole integration. Mayar owns payment UX. You own entitlement state. Keep those jobs separate and the rest stays boring in a good way.

Continue Exploring