🔌 Developer documentation

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.
🔑 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.

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.

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.

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.

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

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);
}

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 — initial public release
  • Key auth (Bearer / X-API-Key) with read:operational & read:billing scopes.
  • 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.