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.
Two exceptions, noted again where they appear:/boulders is offset-based ({ rows, truncated, offset }), and the/alerts lists return a plaindata array with no cursor.
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.
Content reads
Scope: read:operational
Where the metrics endpoints aggregate, these return your gym's actual rows — the live route catalog, the grade scale that dimensions it, and the news feed. All are gym-scoped to the key, cursor-paginated, and PII-free: setter identity, news authorship and member-generated content are deliberately omitted.
GET/api/v1/climbs
Your currently-live climbs (not expired, not archived), newest first.discipline=boulder (the default) orrope. Rope-only fields (climb_type,length_m,draw_count,anchor_id,line_color) are null on boulder rows.
GET/api/v1/routes
A fixed alias for /api/v1/climbs?discipline=rope, byte-identical in shape and paging. Adiscipline param passed here is ignored — this endpoint is the rope view. Both this anddiscipline=rope return403 feature_not_enabledwhen the Rope Climbing module is off for your gym.
GET/api/v1/grade-scale
Your gym's grade levels in order, each with its label, colour and optional V-scale / Fontainebleau equivalents — the key to dimensioning metrics by grade. A short fixed list returned in one page (next_cursoris always null). The scale-level displayfield — numeric (default: label and colour), label_only (label, no grade colour) or color_only (circuit-style swatch, no label) — is a rendering hint only. The ids, labels, colours and order are identical in every mode, so anything keyed on the grade id is unaffected.
GET/api/v1/gym-news
Your currently-visible news posts — the same scheduling window the in-app feed uses — ordered pinned first, then newest. The staff author is never included.
curl "https://api.monkeygrade.cloud/api/v1/climbs?discipline=rope&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 to paid).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);
}