API

Moonlit Pay is the billing layer for Moonlit and partner apps. A customer is an email address. Your server creates a checkout, sends the customer there, and hears back through signed webhooks.

Authentication

Every app has secret keys, created in the dashboard under Developers. Send one as a bearer token from your server. Never expose it in the browser.

Authorization: Bearer mp_sk_...
Base URL: https://pay.moonlit.company/api/v1

Errors return a non-2xx status and { "error": { "code", "message" } }.

Checkout

Create a checkout for one of the app's plans and redirect the customer to the returned URL. Passing email skips the email step. externalId is your own user id and comes back on webhooks.

curl -X POST https://pay.moonlit.company/api/v1/checkout \
  -H "Authorization: Bearer $MOONLIT_PAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "plan": "pro-monthly",
    "email": "ava@example.com",
    "externalId": "user_1042",
    "successUrl": "https://app.example.com/billing?paid=1",
    "cancelUrl": "https://app.example.com/pricing"
  }'

// 201
{ "id": "9f2c...", "url": "https://pay.moonlit.company/checkout/9f2c..." }

successUrl falls back to the app's return URL. Links expire after 24 hours.

Billing portal

Returns a link to the customer's portal with this app's subscription at the top. The customer verifies their email there, and sees subscriptions across every Moonlit and partner app.

curl -X POST https://pay.moonlit.company/api/v1/portal \
  -H "Authorization: Bearer $MOONLIT_PAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "ava@example.com" }'

{ "url": "https://pay.moonlit.company/portal?app=doclet-cloud&email=ava%40example.com" }

Subscriptions

Check what a customer has for this app. entitled is true for active, trialing and past due subscriptions.

GET https://pay.moonlit.company/api/v1/subscriptions?email=ava@example.com

{
  "email": "ava@example.com",
  "active": true,
  "subscriptions": [{
    "id": "k57...", "plan": "pro-monthly", "planName": "Pro",
    "status": "active", "entitled": true,
    "cancelAtPeriodEnd": false, "currentPeriodEnd": 1767225600,
    "externalId": "user_1042"
  }]
}

Plans

GET https://pay.moonlit.company/api/v1/plans

{ "plans": [{ "key": "pro-monthly", "name": "Pro", "type": "subscription",
              "amount": 1900, "currency": "usd", "interval": "month", "trialDays": 7 }] }

Payments

GET https://pay.moonlit.company/api/v1/payments?email=ava@example.com

{ "payments": [{ "id": "in_...", "plan": "pro-monthly", "kind": "subscription",
                 "status": "succeeded", "amount": 1900, "currency": "usd",
                 "description": "Pro", "createdAt": 1764633600 }] }

Webhooks

Add endpoints in the dashboard. Each delivery is a JSON POST signed with your endpoint's secret. Failed deliveries retry for about two and a half hours.

Moonlit-Signature: t=1764633600,v1=5257a869e7ec...

{
  "id": "evt_9a1c...",
  "type": "subscription.created",
  "created": 1764633600,
  "app": "doclet-cloud",
  "data": {
    "customer": { "email": "ava@example.com" },
    "subscription": { "id": "k57...", "status": "active", "plan": "pro-monthly",
                      "amount": 1900, "currency": "usd", "interval": "month",
                      "currentPeriodEnd": 1767225600, "cancelAtPeriodEnd": false,
                      "externalId": "user_1042" }
  }
}

Verify by computing HMAC-SHA256 of `${t}.${rawBody}` with the secret and comparing it to v1 in constant time. Reject timestamps older than five minutes and deduplicate on id.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
checkout.completedA checkout was paid
subscription.createdA subscription became active or started its trial
subscription.updatedStatus, renewal date, price or cancellation changed
subscription.canceledA subscription ended
payment.succeededA one-time or recurring payment went through
payment.failedA charge failed
payment.refundedA payment was fully refunded