🔌 Developer documentation

MonkeyGrade API

A per-gym REST API for your analytics, your route catalog, your billing and your alerting — plus signed webhooks. Server-to-server, key-authenticated. Stable v1 surface under/api/v1.

Reads are the bulk of the surface; the only writes are the alert-rule and acknowledge endpoints, behind their own write:alerts scope.

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.
  • read:analyticsRun natural-language questions and the per-boulder list. A narrower alternative to read:operational, which also grants these.
  • read:alertsRead your alert rules and the event feed.
  • write:alertsCreate, update and delete alert rules, and acknowledge events — the only writes on the surface.
🔑 Show-once

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/v1 surface 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/v1 either.

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.

Discovery

Two tiny, side-effect-free endpoints for confirming a key works and seeing what it may do. Both return the same{ "gym_id": …, "scopes": [ … ] }shape — the gym is resolved from the key, never from anything you send.

GET/api/v1/whoamino scope

Identity round-trip: which gym this key belongs to and which scopes it holds. Needs only a valid, feature-enabled key — no particular scope — so it's the right first call when wiring up an integration.

GET/api/v1/scope-checkread:operational

The same payload behind a scope guard — a key withoutread:operational gets403 insufficient_scope. Useful for asserting a key is correctly scoped before you depend on it.

Operational endpoints

Scope: read:operational (the last two also accept read:analytics)

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.

GET/api/v1/catalog

List the metrics, dimensions and filters your key may resolve (scope-filtered).

GET/api/v1/metrics/{metric_id}

Run one metric. Add dimensions=wall,grade,date_from / date_to, and repeatable filter[<id>]=… params.

POST/api/v1/query

Body-based variant for multi-dimension queries — select the metric, dimensions and filters as JSON.

POST/api/v1/query/nlread:analytics or read:operational

Ask a plain-English question — { "text": "how many boulders set between 40 and 50 days ago?" } — and get back the interpretation, the resolved spec and theresult. Parsing is a deterministic local parser over the same catalog — no external LLM, and nothing leaves our infrastructure. A question that names nothing measurable returns422 unknown_metric with suggestions.

GET/api/v1/bouldersread:analytics or read:operational

The "which ones" companion to the aggregate metrics: one row per boulder matching repeatable filter[<id>]=… params (e.g. filter[age_days_gte]=30) over the boulders-domain allowlist, plus offset. Returns { rows, truncated, offset } — offset-based, not the cursor envelope. An unknown filter returns422.

curl "https://api.monkeygrade.cloud/api/v1/metrics/boulders.active?dimensions=wall,grade" \
  -H "Authorization: Bearer mg_live_7Fk2_…"

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_…"

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.

GET/api/v1/billing/invoices

List invoices, newest first, cursor-paginated. Filter bystatus,period_from /period_to; passinclude=draft to opt drafts in. List items omit line items.

GET/api/v1/billing/invoices/{invoice_id}

One invoice with its line items and the gym's bill_to legal identity.

GET/api/v1/billing/invoices/{invoice_id}/payments

An 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_…"

Alerts

Scopes: read:alerts (reads) · write:alerts (writes)

Manage the same alert rules as the in-app admin surface — e.g. "tell me when a boulder has been up more than 30 days" — and read the events they raise. This is theonly writable part of the API, which is why the write verbs sit behind their own scope: a key can be givenread:alerts alone to observe without being able to change anything.

GET/api/v1/alerts/rulesread:alerts

Your gym's alert rules, newest first, under data.

POST/api/v1/alerts/ruleswrite:alerts

Create a rule. Required: name,rule_type,channels,recipients; optionalconfig,cadence,weekly_dow,cooldown_days,enabled. Returns201. Anygym_id in the body is ignored — the key is the tenant.

PATCHDELETE/api/v1/alerts/rules/{rule_id}write:alerts

Partially update or delete one rule (deleting cascades to its events). Both are validated by the same engine the admin UI uses.

GET/api/v1/alerts/eventsread:alerts

The events your rules raised, newest first, capped at 200 rows. Filter withstatus=active,acknowledged orresolved. Each row carries itsrule_name.

POST/api/v1/alerts/events/{event_id}/ackwrite:alerts

Acknowledge one event. Because there's no member session on this surface, the acknowledgement is recorded without an attributed admin.

↩︎ Two things to know

The alerts lists return a plain dataarray — they don't carry the cursor envelope the other list endpoints use, so there's nopage object to follow. And a rule or event belonging to another gym returns exactly the same404 as one that doesn't exist — deliberately, so ids can't be probed for existence.

curl "https://api.monkeygrade.cloud/api/v1/alerts/events?status=active" \
  -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);
}

Custom integrations

MonkeyGrade is API-first — everything above (the public API and signed webhooks) is the same interface we use ourselves, so it's a solid foundation for connecting your gym's other systems. Beyond the self-serve surface, we also build custom integrationswith a gym's existing access-control and membership/billing systems on request — each one scoped and quoted per project.

Have a system you'd like to connect —door access, membership management, or your own billing/ERP? Tell us about itand we'll scope the work.

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.

v1 — the current surface
  • Key auth (Bearer / X-API-Key) with five scopes: read:operational, read:billing, read:analytics, read:alerts, write:alerts.
  • Discovery: /whoami, /scope-check.
  • Operational reads: /catalog, /metrics/<id>, /query, plus /query/nl (natural language) and /boulders (per-boulder rows).
  • Content reads: /climbs, /routes, /grade-scale, /gym-news.
  • Billing reads: invoices, invoice detail, payments.
  • Alerts: rule list / create / update / delete, the event feed, and event acknowledgement.
  • Signed webhooks: invoice.issued, invoice.status_changed, payment.recorded.

Questions or need a higher rate limit?Get in touch.