Self-service

Customer portal

Mint a session for a customer, then let them cancel, pause, change plan, or pull invoices from buttons in your own app. The token is the only credential, so customers never log into MeridaPay.

How it works

Mint a portal session for a specific customer with your API key. You get back a single-use token, scoped to that customer and good for 24 hours. Pass the token to the portal REST endpoints to cancel, pause, change a plan, or pull invoices on the customer's behalf, with no MeridaPay login required.

Keep the token on your server and drive the endpoints from buttons in your own app. The customer manages their subscription without ever leaving your product. The Add a cancel button to your app section below walks through the full flow.

Mint a session

Create a session for the customer you want to give access to. Authenticate with your secret API key. Look the customer up by their MeridaPay id, your own external_customer_id, or their email.

bash
curl -X POST https://api.meridapay.com/portal/sessions \
  -H "Authorization: Bearer mrd_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "customer_email": "[email protected]" }'
customer_iduuid

The MeridaPay customer UUID. Provide exactly one of the three lookup keys.

external_customer_idstring

Your own customer id, the same value you passed on the original checkout.

customer_emailstring

The customer's email. Use it when that's all you have, for example a buyer who paid through a hosted buy link. Resolves only when exactly one customer matches. If several share the email you get a 409, so resolve the exact one with GET /customers and pass customer_id instead.

json
{
  "id": "ptk_8f5f7c7e...",
  "expires_at": "2026-06-05T12:00:00Z"
}

id is the session token. Pass it in the path of the endpoints below. Sessions expire after 24 hours, so mint a fresh one per visit.

REST endpoints

Every endpoint takes the token in the URL path, accepts JSON, and returns JSON. There's no Bearer header. The token is the auth.

Cancel a subscription

bash
curl -X POST https://api.meridapay.com/portal/<token>/cancel \
  -H "Content-Type: application/json" \
  -d '{
    "subscription_id": "<subscription-uuid>",
    "mode": "period_end"
  }'
subscription_iduuidrequired

The subscription to cancel. Must belong to the account this token was minted for.

mode"immediate" | "period_end"

Default "period_end". See Subscriptions.

Returns { id, cancellation_effective_at, is_active }. Fires subscription.canceled webhook.

Pause a subscription

bash
curl -X POST https://api.meridapay.com/portal/<token>/pause \
  -H "Content-Type: application/json" \
  -d '{
    "subscription_id": "<subscription-uuid>",
    "resumes_at": "2026-08-01T00:00:00Z",
    "keep_invoices": false
  }'
subscription_iduuidrequired

The subscription to pause.

resumes_atISO 8601

When the subscription should auto-resume. If omitted, pause is open-ended and only ends with an explicit resume call.

keep_invoicesboolean

When true, invoices keep generating on the normal schedule but charges are deferred. Default false: no invoices while paused.

Resume a paused subscription

bash
curl -X POST https://api.meridapay.com/portal/<token>/resume \
  -H "Content-Type: application/json" \
  -d '{ "subscription_id": "<subscription-uuid>" }'

Undo a scheduled cancellation

bash
curl -X POST https://api.meridapay.com/portal/<token>/uncancel \
  -H "Content-Type: application/json" \
  -d '{ "subscription_id": "<subscription-uuid>" }'

Only valid while a period_end cancellation is still pending. After the period ends, the subscription is gone. Re-subscribe via a fresh checkout.

Errors

Every endpoint returns 400 with { "error": <message-or-issues> } when the body fails validation or the token doesn't authorise the operation. There's no separate 401: an invalid token surfaces as 400 invalid token.

Add a cancel button to your app

You don't have to send customers anywhere. Keep the session token on your server and let them cancel (or pause, or change plan) from a button in your own UI. The customer never sees the token.

1. On your server

Mint a session for the signed-in customer, then cancel the subscription. Capture the subscription_id when the subscription starts: the subscription.created webhook carries it as subscriptionId. Store it against your user. (The signup payment.succeeded carries the same id as subscriptionId too.)

ts
// POST /account/cancel (your backend, behind your own auth)
const session = await fetch("https://api.meridapay.com/portal/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MERIDA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ external_customer_id: user.id }),
}).then((r) => r.json())

await fetch(`https://api.meridapay.com/portal/${session.id}/cancel`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ subscription_id: subscriptionId, mode: "period_end" }),
})

2. In your UI

The button just calls your own endpoint. The token stays server-side.

tsx
<button onClick={() => fetch("/account/cancel", { method: "POST" })}>
  Cancel subscription
</button>

The same token works for pause, resume, uncancel, and change-plan, so the same pattern powers a full set of self-service controls without shipping a hosted page.