MonkeyGrade API
A read-only, per-gym REST API for your operational data and your billing — plus signed webhooks. Server-to-server, key-authenticated. Stable v1 surface under/api/v1.
Public API & webhooks is a paid add-on. Enable it for your gym in the app, then create a key.
Getting a key
API keys are issued per gym by a gym admin. In the app, go toSettings → Developers / API and create a key. Each key is scoped — pick the access it needs:
read:operationalRead the operational catalog and run metrics (boulders, sends, member stats).read:billingRead invoices, line items and payments.
The full plaintext key (mg_<env>_<keyid>_<secret>) is shown exactly once, at creation — the server stores only its SHA-256 hash. Copy it then; if you lose it, rotate the key. Keys can be rotated (with an optional grace window) and revoked at any time.
Don't have the add-on yet? Enable Public API & webhooks for your gym from the pricing page, then open the Developers panel in the app.
Authentication
All programmatic requests go to the dedicated API hosthttps://api.monkeygrade.cloud— never the browser www /app origins. To test against staging, swap the host forhttps://api-staging.monkeygrade.cloud.
Send your key on every request, either as a Bearer token (canonical) or via theX-API-Key header:
curl https://api.monkeygrade.cloud/api/v1/whoami \
-H "Authorization: Bearer mg_live_7Fk2_…"
# or, equivalently:
curl https://api.monkeygrade.cloud/api/v1/whoami \
-H "X-API-Key: mg_live_7Fk2_…"The key resolves your gym (the tenant) server-side — you never supply a gym id, and a key can only ever see its own gym's data. A quick round-trip:GET /api/v1/whoamireturns { "gym_id": …, "scopes": [ … ] }.
- •Server-to-server only. The
/api/v1surface sends no CORS headers — never embed a key in browser JavaScript or a mobile app. - •TLS required. All calls go over HTTPS.
- •Its own auth domain. A public-API key is a separate credential class — it does not accept the member session cookie or member Bearer token, and those don't work on
/api/v1either.
Conventions
Errors
Every 4xx/5xx on the public surface returns a stable JSON shape with a human message, a machine code, and a correlatable request_id:
{
"error": "The key lacks the read:billing scope.",
"code": "insufficient_scope",
"request_id": "req_8c1f…"
}Codes are a small documented enum — e.g. unauthorized,key_expired,insufficient_scope,feature_not_enabled,not_found,validation_error,unknown_metric,rate_limited,server_error. Branch on code, not on the message.
Pagination
List endpoints wrap rows in data and carry an opaque cursor. To page, echo next_cursorback as the cursor query param — never construct or parse it yourself. Stop whenhas_more is false.
{
"data": [ … ],
"page": { "next_cursor": "b2Zmc2V0OjUw…", "has_more": true }
}Pass limit (1–200, default 50) to size each page.
Rate limits
Requests are rate-limited per key. Every response carriesRateLimit-* headers (limit, remaining, reset). When you exceed the limit you get429 withcode: "rate_limited" and aRetry-After header — back off and retry after it.
Operational endpoints
Scope: read:operational
Operational data is exposed as allowlisted metrics with allowlisted dimensions and filters — you select names, never SQL, and your gym is injected server-side. Discover what's available with the catalog, then run a metric.
/api/v1/catalogList the metrics, dimensions and filters your key may resolve (scope-filtered).
/api/v1/metrics/{metric_id}Run one metric. Add dimensions=wall,grade,date_from / date_to, and repeatable filter[<id>]=… params.
/api/v1/queryBody-based variant for multi-dimension queries — select the metric, dimensions and filters as JSON.
curl "https://api.monkeygrade.cloud/api/v1/metrics/boulders.active?dimensions=wall,grade" \
-H "Authorization: Bearer mg_live_7Fk2_…"Billing → ERP endpoints
Scope: read:billing
Pull your gym's invoices, line items and payments to reconcile in your ERP or accounting tool. Money fields are integer minor units (e.g. CHF cents); paymentamount is signed (a refund is negative). Drafts are excluded by default.
/api/v1/billing/invoicesList invoices, newest first, cursor-paginated. Filter bystatus,period_from /period_to; passinclude=draft to opt drafts in. List items omit line items.
/api/v1/billing/invoices/{invoice_id}One invoice with its line items and the gym's bill_to legal identity.
/api/v1/billing/invoices/{invoice_id}/paymentsAn invoice's payment rows (the reconciliation ledger), cursor-paginated. Signed minor units; PII-minimal (never the recording admin).
curl "https://api.monkeygrade.cloud/api/v1/billing/invoices?status=issued&limit=50" \
-H "Authorization: Bearer mg_live_7Fk2_…"Webhooks
Instead of polling, register an HTTPS endpoint and we'll POST signed events to it. Set this up in the app under Settings → Developers / API → Webhooks: add an https:// URL and pick the events. The signing secret (whsec_…) is shownonce at creation — store it securely to verify deliveries.
Events
invoice.issuedAn invoice was issued.invoice.status_changedAn invoice changed state (e.g. settled topaid).payment.recordedA payment (or refund) was recorded against an invoice.
Signature scheme
Each delivery carries an X-MonkeyGrade-Signatureheader and a stable X-MonkeyGrade-Deliveryid (dedupe on it — retries reuse it). The signature follows the Stripe-style scheme:
X-MonkeyGrade-Signature: t=<timestamp>,v1=<hmac_sha256(secret, "<timestamp>.<raw_body>")>Recompute the HMAC-SHA256 of "<t>.<raw_body>"with your whsec_ secret and compare it (in constant time) to v1. Sign over theraw bytes of the body — don't re-serialize the JSON. Reject deliveries whose t is too old (replay protection). Endpoint URLs are SSRF-validated, so they must be public HTTPS hosts.
Verify in Python
import hashlib
import hmac
def verify_monkeygrade_signature(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
"""Verify an X-MonkeyGrade-Signature header (scheme: t=<ts>,v1=<hmac>).
secret -- your endpoint's whsec_... signing secret (shown once at creation)
header -- the raw X-MonkeyGrade-Signature header value
body -- the raw request body BYTES, exactly as received (do not re-serialize)
"""
import time
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
return False
# Reject stale timestamps (replay protection).
if abs(time.time() - int(ts)) > tolerance:
return False
signed_payload = f"{ts}.".encode() + body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Verify in JavaScript (Node)
import crypto from "node:crypto";
// secret -- your endpoint's whsec_... signing secret (shown once at creation)
// header -- the raw X-MonkeyGrade-Signature header value (t=<ts>,v1=<hmac>)
// body -- the raw request body STRING/Buffer, exactly as received
function verifyMonkeyGradeSignature(secret, header, body, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim()))
);
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
// Reject stale timestamps (replay protection).
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
const signedPayload = `${ts}.${body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time compare.
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Changelog
MonkeyGrade ships continuously; the /api/v1surface is versioned by date. Additive changes (new metrics, new optional fields) won't break you — branch on documented fields and error codes only.
- Key auth (Bearer / X-API-Key) with
read:operational&read:billingscopes. - Operational reads:
/catalog,/metrics/<id>,/query. - Billing reads: invoices, invoice detail, payments.
- Signed webhooks:
invoice.issued,invoice.status_changed,payment.recorded.
Questions or need a higher rate limit?Get in touch.