An API that respects your afternoon.
One call starts a checkout. One script opens it over your site. Signed webhooks tell you the rest. Everything works in test mode with sandbox MoMo before a single cedi moves.
One call starts a checkout. One script opens it over your site. Signed webhooks tell you the rest. Everything works in test mode with sandbox MoMo before a single cedi moves.
// Your server: start a checkout for a plan
const res = await fetch('https://api.usemonthly.app/v1/checkout-sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MONTHLY_KEY}`, // mk_test_…
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId: '7c1f…',
successUrl: 'https://streamgh.com/welcome?session={CHECKOUT_SESSION_ID}',
cancelUrl: 'https://streamgh.com/',
clientReferenceId: user.id,
}),
});
const { url } = await res.json();
// Send the customer to url, or open it in the pop-up<!-- Your page: the same checkout, over your site -->
<script src="https://checkout.usemonthly.app/v1/monthly.js"></script>
<script>
button.addEventListener('click', async () => {
const { url } = await fetch('/checkout-session', { method: 'POST' })
.then((r) => r.json());
Monthly.openCheckout({
url,
onSuccess: (result) => location.assign('/welcome?session=' + result.sessionId),
onCancel: () => {},
});
});
</script>// Your server: check Monthly-Signature before trusting an event
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) { // secret: whsec_…
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
// Then act on event.type, once per event.id:
// subscription.renewed, .payment_failed, .paused, .resumed, .cancelled …# Create a plan: GHS 45 a month, 7 days free
curl https://api.usemonthly.app/v1/plans \
-H "Authorization: Bearer $MONTHLY_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium",
"pricePesewas": 4500,
"billingCycle": "monthly",
"trialDays": 7
}'Amounts are whole pesewas, lists are newest first, and every object belongs to test or live mode. A key only ever sees its own mode.
Sign up, and create an mk_test_ key under Developers. It’s shown once; we only keep its hash.
In the dashboard or with the API: a name, a price in pesewas, a cycle, and an optional trial.
Your server creates a checkout session and gets back a url, valid for 24 hours.
Add a webhook endpoint, check the signature, and act on each event once.
Both use the same session. Redirect to its url, or load monthly.js and call Monthly.openCheckout. It becomes a bottom sheet on phones.
The sheet lives in a closed shadow root and a sandboxed iframe: your CSS can’t break it, and it can’t navigate your page.
The checkout shows nothing in a frame until your page answers a handshake from the origin of your successUrl or cancelUrl. Nobody else can frame it.
After the handshake, checkout talks only to your origin, and the SDK only listens to its own iframe.
A real modal: focus moves in and comes back, Escape closes it, and the page behind doesn’t scroll.
Each delivery carries Monthly-Signature: an HMAC-SHA256 of the timestamp and raw body, keyed with your endpoint’s whsec_ secret. Anything but a 2xx is retried 8 more times over about three days.
checkout.session.completedA customer paid, or started a trial, through checkout. Carries your clientReferenceId.subscription.trial_startedA plan with a trial began. Nothing was charged.subscription.activatedThe first payment went through.subscription.renewedA renewal or a retry was paid.subscription.payment_failedA charge was declined. Retries are scheduled.subscription.pausedThe last retry failed, or the customer paused.subscription.resumedBack on, after a pause.subscription.cancelledEnded, now or at the end of the paid period.Delivery is at least once and can be out of order: deduplicate on the event id. Monthly never calls private addresses, and live endpoints must use https.
Send Authorization: Bearer mk_test_… from your server. Never from a browser: secret keys stay secret.
/v1/plansCreate a plan/v1/checkout-sessionsStart a checkout, get its url/v1/subscriptionsSubscribers, filtered by status or plan/v1/subscriptions/:id/cancelCancel now or at period end/v1/subscriptions/:id/paymentsEvery charge attempt, with Monthly’s fee/v1/payments/:id/refundsRefund some or all, to credits/v1/eventsEverything that happened, to catch up/v1/reports/paymentsSuccessful payments per dayTest mode never moves real money. Use these to walk every path, including the whole failed-payment journey.
Create a test key in a couple of minutes. Nothing to install on your server but a fetch call.