{
  "openapi": "3.1.0",
  "info": {
    "title": "RugbyMetrics Connect",
    "version": "1.0.0",
    "summary": "Send GPS and wearable session data into RugbyMetrics.",
    "description": "Device data API v1. v1 promise: a field never changes meaning or unit and a range is never narrowed; new fields are additive. Docs: https://rugbymetrics.com/connect",
    "contact": {
      "name": "RugbyMetrics",
      "email": "hello@rugbymetrics.com",
      "url": "https://rugbymetrics.com/connect"
    }
  },
  "servers": [
    {
      "url": "https://app.rugbymetrics.com/api/connect/v1"
    }
  ],
  "security": [
    {
      "bearerKey": []
    }
  ],
  "paths": {
    "/sessions": {
      "post": {
        "operationId": "postSession",
        "summary": "Send one session",
        "tags": [
          "Connect"
        ],
        "description": "Creates the session, or updates it when the same vendor and session.external_id was sent before. Writes the same tables as the GPS file import.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SessionRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The session already existed and was updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "validate_only": {
                      "type": "boolean"
                    },
                    "session": {
                      "type": "string",
                      "enum": [
                        "created",
                        "updated",
                        "would_create",
                        "would_update"
                      ]
                    },
                    "athletes_matched": {
                      "type": "integer"
                    },
                    "rows_created": {
                      "type": "integer"
                    },
                    "rows_updated": {
                      "type": "integer"
                    },
                    "rows_written": {
                      "type": "integer"
                    },
                    "metrics_stored": {
                      "type": "integer",
                      "description": "Metric values stored, after heart rate was dropped for players without a wellness consent."
                    },
                    "hr_values_dropped": {
                      "type": "integer"
                    },
                    "skipped_no_data_consent": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Athlete ids of players without a data consent; nothing was stored for them."
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "The session was created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "validate_only": {
                      "type": "boolean"
                    },
                    "session": {
                      "type": "string",
                      "enum": [
                        "created",
                        "updated",
                        "would_create",
                        "would_update"
                      ]
                    },
                    "athletes_matched": {
                      "type": "integer"
                    },
                    "rows_created": {
                      "type": "integer"
                    },
                    "rows_updated": {
                      "type": "integer"
                    },
                    "rows_written": {
                      "type": "integer"
                    },
                    "metrics_stored": {
                      "type": "integer",
                      "description": "Metric values stored, after heart rate was dropped for players without a wellness consent."
                    },
                    "hr_values_dropped": {
                      "type": "integer"
                    },
                    "skipped_no_data_consent": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Athlete ids of players without a data consent; nothing was stored for them."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body is not valid, a value is out of range, an athlete id is not mapped, or the match is not the team's.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "errors"
                  ],
                  "properties": {
                    "ok": {
                      "const": false
                    },
                    "errors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "field",
                          "message"
                        ],
                        "properties": {
                          "field": {
                            "type": "string",
                            "examples": [
                              "athletes[1].metrics.max_speed_ms"
                            ]
                          },
                          "message": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "unknown_athlete_ids": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Present when athlete ids are not mapped to players."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No key, a malformed key, an unknown key or a revoked key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "The club is not on a paid plan, or the organisation is read-only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "The body is larger than 256 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "More than 60 requests in a minute with this key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            },
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait."
              }
            }
          },
          "503": {
            "description": "Temporarily unavailable. Send the same request again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/validate": {
      "post": {
        "operationId": "validateSession",
        "summary": "Check a session without storing it (\"Connect Verified\" self-test)",
        "tags": [
          "Connect"
        ],
        "description": "Same body and same checks as /sessions. Writes nothing. Counts toward the rate limit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SessionRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The body passed every check.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "validate_only": {
                      "type": "boolean"
                    },
                    "session": {
                      "type": "string",
                      "enum": [
                        "created",
                        "updated",
                        "would_create",
                        "would_update"
                      ]
                    },
                    "athletes_matched": {
                      "type": "integer"
                    },
                    "rows_created": {
                      "type": "integer"
                    },
                    "rows_updated": {
                      "type": "integer"
                    },
                    "rows_written": {
                      "type": "integer"
                    },
                    "metrics_stored": {
                      "type": "integer",
                      "description": "Metric values stored, after heart rate was dropped for players without a wellness consent."
                    },
                    "hr_values_dropped": {
                      "type": "integer"
                    },
                    "skipped_no_data_consent": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Athlete ids of players without a data consent; nothing was stored for them."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body is not valid, a value is out of range, an athlete id is not mapped, or the match is not the team's.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "errors"
                  ],
                  "properties": {
                    "ok": {
                      "const": false
                    },
                    "errors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "field",
                          "message"
                        ],
                        "properties": {
                          "field": {
                            "type": "string",
                            "examples": [
                              "athletes[1].metrics.max_speed_ms"
                            ]
                          },
                          "message": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "unknown_athlete_ids": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Present when athlete ids are not mapped to players."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No key, a malformed key, an unknown key or a revoked key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "The club is not on a paid plan, or the organisation is read-only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "The body is larger than 256 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "More than 60 requests in a minute with this key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            },
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait."
              }
            }
          },
          "503": {
            "description": "Temporarily unavailable. Send the same request again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "rmk_ + 40 hex characters",
        "description": "An API key made by a club administrator in Team settings, RugbyMetrics Connect."
      }
    },
    "schemas": {
      "SessionRequest": {
        "type": "object",
        "required": [
          "vendor",
          "session",
          "athletes"
        ],
        "properties": {
          "vendor": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._-]{1,30}$",
            "description": "Your system's name. Athlete ids are mapped per vendor. Stored in lower case."
          },
          "session": {
            "$ref": "#/components/schemas/Session"
          },
          "athletes": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/Athlete"
            }
          }
        }
      },
      "Session": {
        "type": "object",
        "required": [
          "external_id",
          "start",
          "end",
          "type"
        ],
        "properties": {
          "external_id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._:-]{1,64}$",
            "description": "Your id for the session. The same vendor and external_id updates, never duplicates."
          },
          "start": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 with an offset.",
            "examples": [
              "2026-10-20T19:00:00+02:00"
            ]
          },
          "end": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 with an offset. After start; at most 200 minutes."
          },
          "type": {
            "type": "string",
            "enum": [
              "training",
              "match"
            ]
          },
          "fixture_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "A RugbyMetrics match of the key's team."
          }
        }
      },
      "Athlete": {
        "type": "object",
        "required": [
          "vendor_athlete_id"
        ],
        "properties": {
          "vendor_athlete_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          },
          "metrics": {
            "$ref": "#/components/schemas/Metrics"
          }
        }
      },
      "Metrics": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "distance_m": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 20000,
            "description": "Total distance covered in the session. Unit: metres."
          },
          "hsr_m": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 20000,
            "description": "High-speed running distance. Unit: metres."
          },
          "sprint_m": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 20000,
            "description": "Sprint distance. Unit: metres."
          },
          "sprints": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 500,
            "description": "Number of sprints. Unit: count."
          },
          "max_speed_ms": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 12.5,
            "description": "Top speed. 12.5 m/s is the highest value accepted. Unit: metres per second."
          },
          "accels": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 2000,
            "description": "Number of accelerations. Unit: count."
          },
          "decels": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 2000,
            "description": "Number of decelerations. Unit: count."
          },
          "player_load": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 5000,
            "description": "The vendor's player load figure. Unit: arbitrary units."
          },
          "hr_avg": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 30,
            "maximum": 230,
            "description": "Average heart rate. Stored only for players with a wellness consent. Unit: beats per minute. Health data: dropped for players without a wellness consent."
          },
          "hr_max": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 30,
            "maximum": 250,
            "description": "Maximum heart rate. Stored only for players with a wellness consent. Unit: beats per minute. Health data: dropped for players without a wellness consent."
          },
          "impacts": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 2000,
            "description": "Number of impacts. Unit: count."
          }
        },
        "description": "All optional. Units are fixed as named."
      }
    }
  }
}
