{
	"openapi": "3.1.0",
	"info": {
		"title": "onRun Public API",
		"version": "1.0.0",
		"description": "Agent-facing API for onRun: local running events, runners, clubs, and a runner's public race highlights. Most endpoints are unauthenticated and only ever return data that is already public on onrun.com. Two surfaces require a Firebase ID token and act on behalf of that specific user: /api/v1/me (read) and /api/v1/checkout_sessions* (write — an Agentic Commerce Protocol checkout that registers the user for a paid event). See /auth.md, and /.well-known/acp.json for ACP discovery.",
		"x-service-info": {
			"title": "onRun Agent Commerce",
			"description": "Running race registrations, club memberships, and event ticketing",
			"categories": ["sports", "fitness", "events", "ticketing", "community"],
			"pricing": "freemium"
		}
	},
	"servers": [{ "url": "https://onrun.com" }],
	"security": [],
	"paths": {
		"/api/v1/events": {
			"get": {
				"summary": "Search public events",
				"operationId": "searchEvents",
				"parameters": [
					{ "name": "city", "in": "query", "schema": { "type": "string" }, "description": "Exact-match, case-sensitive city filter." },
					{ "name": "state", "in": "query", "schema": { "type": "string" }, "description": "Exact-match, case-sensitive state filter." },
					{ "name": "type", "in": "query", "schema": { "type": "string" }, "description": "Exact-match event type filter." },
					{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 }, "description": "Capped at 50 regardless of requested value." }
				],
				"responses": {
					"200": {
						"description": "Matching public events.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object",
									"properties": {
										"count": { "type": "integer" },
										"events": { "type": "array", "items": { "$ref": "#/components/schemas/Event" } }
									},
									"required": ["count", "events"]
								}
							}
						}
					}
				}
			}
		},
		"/api/v1/events/{id}": {
			"get": {
				"summary": "Get a single public event",
				"operationId": "getEvent",
				"parameters": [
					{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
				],
				"responses": {
					"200": {
						"description": "The event.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Event" } } }
					},
					"404": {
						"description": "The event does not exist or is private.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFound" } } }
					}
				}
			}
		},
		"/api/v1/profile/{id}/races": {
			"get": {
				"summary": "Get a runner's public race highlights",
				"operationId": "getProfileRaces",
				"parameters": [
					{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The runner's user id." }
				],
				"responses": {
					"200": {
						"description": "The runner's profile and public race highlights.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object",
									"properties": {
										"id": { "type": "string" },
										"displayName": { "type": "string" },
										"handle": { "type": "string", "nullable": true },
										"url": { "type": "string", "format": "uri" },
										"races": { "type": "array", "items": { "$ref": "#/components/schemas/RaceHighlight" } }
									},
									"required": ["id", "displayName", "url", "races"]
								}
							}
						}
					},
					"404": {
						"description": "The profile does not exist or is private.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFound" } } }
					}
				}
			}
		},
		"/api/v1/runners": {
			"get": {
				"summary": "Search public runners",
				"operationId": "searchRunners",
				"parameters": [
					{ "name": "q", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Case-insensitive prefix match against the runner's display name." },
					{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 }, "description": "Capped at 50 regardless of requested value." }
				],
				"responses": {
					"200": {
						"description": "Matching public runners.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object",
									"properties": {
										"count": { "type": "integer" },
										"runners": { "type": "array", "items": { "$ref": "#/components/schemas/Runner" } }
									},
									"required": ["count", "runners"]
								}
							}
						}
					},
					"400": {
						"description": "Missing required `q` query parameter.",
						"content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string", "const": "missing_query" } }, "required": ["error"] } } }
					}
				}
			}
		},
		"/api/v1/clubs": {
			"get": {
				"summary": "List public running clubs",
				"operationId": "listClubs",
				"responses": {
					"200": {
						"description": "Public running clubs.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object",
									"properties": {
										"count": { "type": "integer" },
										"clubs": { "type": "array", "items": { "$ref": "#/components/schemas/Club" } }
									},
									"required": ["count", "clubs"]
								}
							}
						}
					}
				}
			}
		},
		"/api/v1/me": {
			"get": {
				"summary": "Get the authenticated user's own profile",
				"operationId": "getMe",
				"description": "Requires a Firebase ID token for an onRun user, obtained through that user's own sign-in flow — see /auth.md. Returns only the caller's own data, scoped server-side to the token's uid.",
				"security": [{ "bearerAuth": [] }],
				"responses": {
					"200": {
						"description": "The caller's own profile.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } }
					},
					"401": {
						"description": "Missing or invalid bearer token."
					},
					"404": {
						"description": "The caller's user document does not exist.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFound" } } }
					}
				}
			}
		},
		"/api/v1/checkout_sessions": {
			"post": {
				"summary": "Create an ACP checkout session for a paid event",
				"operationId": "createCheckoutSession",
				"description": "Agentic Commerce Protocol (2026-04-17) agentic-checkout create. Holds a spot for the authenticated user through onRun's production capacity/discount transaction and opens a real Stripe PaymentIntent on the organizer's Connect account; the PaymentIntent id and client secret come back in `metadata` so the buyer can confirm it. Auth is the end user's Firebase ID token, not an agent-platform credential — onRun is not enrolled for Stripe Shared Payment Tokens. See /auth.md.",
				"security": [{ "bearerAuth": [] }],
				"x-payment-info": {
					"intent": "session",
					"method": "stripe",
					"amount": "dynamic",
					"currency": "USD",
					"description": "Event registration checkout session via Stripe Connect"
				},
				"parameters": [{ "$ref": "#/components/parameters/AcpIdempotencyKey" }],
				"requestBody": {
					"required": true,
					"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSessionCreateRequest" } } }
				},
				"responses": {
					"201": {
						"description": "Session created; a spot is held until `expires_at`.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSession" } } }
					},
					"400": { "description": "Malformed request, or a missing `Idempotency-Key` header.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"401": { "description": "Missing or invalid Firebase ID token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"404": { "description": "The event does not exist or is private.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"409": { "description": "The event is sold out, or the caller already holds a reservation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"422": { "description": "The event cannot be checked out (no organizer, currency mismatch, or an unusable discount code).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"429": { "description": "Rate limited.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"503": { "description": "Checkout backend temporarily unavailable.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } }
				}
			}
		},
		"/api/v1/checkout_sessions/{id}": {
			"get": {
				"summary": "Retrieve an ACP checkout session",
				"operationId": "getCheckoutSession",
				"description": "Returns the caller's own session. A session belonging to another user is reported as 404, so this cannot be used to probe for other reservation ids. The Stripe client secret is returned only on create, never here.",
				"security": [{ "bearerAuth": [] }],
				"parameters": [{ "$ref": "#/components/parameters/AcpSessionId" }],
				"responses": {
					"200": {
						"description": "The session.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSession" } } }
					},
					"401": { "description": "Missing or invalid Firebase ID token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"404": { "description": "No such session for this caller.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } }
				}
			},
			"post": {
				"summary": "Update a checkout session (not supported)",
				"operationId": "updateCheckoutSession",
				"description": "The ACP update endpoint is deliberately not implemented and always returns 405. An onRun session is a single event at quantity 1 with no shipping address and an immutable post-reservation price, so there is nothing an update could legitimately change — cancel and create a new session instead.",
				"security": [{ "bearerAuth": [] }],
				"parameters": [{ "$ref": "#/components/parameters/AcpSessionId" }, { "$ref": "#/components/parameters/AcpIdempotencyKey" }],
				"responses": {
					"405": { "description": "Not supported by this merchant.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } }
				}
			}
		},
		"/api/v1/checkout_sessions/{id}/complete": {
			"post": {
				"summary": "Complete an ACP checkout session",
				"operationId": "completeCheckoutSession",
				"description": "Finalizes the registration. The credential must be the Stripe PaymentIntent id from the session's `metadata`, sent with `handler_id: \"onrun_stripe_delegated\"` and `credential.type: \"onrun_stripe_payment_intent\"`; onRun re-verifies it with Stripe and checks it belongs to this session before writing. If the charge has not succeeded the session comes back with status `requires_escalation` and a `continue_url` — a success is never reported that did not happen. Stripe Shared Payment Tokens are rejected with 422.",
				"security": [{ "bearerAuth": [] }],
				"x-payment-info": {
					"intent": "charge",
					"method": "stripe",
					"amount": "dynamic",
					"currency": "USD",
					"description": "Finalize event registration payment via Stripe Connect"
				},
				"parameters": [{ "$ref": "#/components/parameters/AcpSessionId" }, { "$ref": "#/components/parameters/AcpIdempotencyKey" }],
				"requestBody": {
					"required": true,
					"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSessionCompleteRequest" } } }
				},
				"responses": {
					"200": {
						"description": "Session with status `completed` (an `order` is attached) or `requires_escalation`.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSession" } } }
					},
					"400": { "description": "Malformed request, or a missing `Idempotency-Key` header.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"401": { "description": "Missing or invalid Firebase ID token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"404": { "description": "No such session for this caller.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"422": { "description": "Unsupported payment handler or credential type (including `spt`), or a credential that does not match this session.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } }
				}
			}
		},
		"/api/v1/checkout_sessions/{id}/cancel": {
			"post": {
				"summary": "Cancel an ACP checkout session",
				"operationId": "cancelCheckoutSession",
				"description": "Releases the held spot and cancels the Stripe PaymentIntent. A completed session cannot be canceled — a paid registration is refunded through onRun's refund flow instead.",
				"security": [{ "bearerAuth": [] }],
				"parameters": [{ "$ref": "#/components/parameters/AcpSessionId" }, { "$ref": "#/components/parameters/AcpIdempotencyKey" }],
				"responses": {
					"200": {
						"description": "Session with status `canceled`.",
						"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpCheckoutSession" } } }
					},
					"401": { "description": "Missing or invalid Firebase ID token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"404": { "description": "No such session for this caller.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } },
					"405": { "description": "The session has completed and can no longer be canceled.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcpError" } } } }
				}
			}
		}
	},
	"components": {
		"parameters": {
			"AcpIdempotencyKey": {
				"name": "Idempotency-Key",
				"in": "header",
				"required": true,
				"description": "Required on every ACP POST. onRun derives the reservation's deterministic id from this key, so retrying a create replays the original reservation instead of holding a second spot.",
				"schema": { "type": "string", "maxLength": 255 }
			},
			"AcpSessionId": {
				"name": "id",
				"in": "path",
				"required": true,
				"description": "The `checkout_session_id` returned by POST /api/v1/checkout_sessions.",
				"schema": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,256}$" }
			}
		},
		"securitySchemes": {
			"bearerAuth": {
				"type": "http",
				"scheme": "bearer",
				"bearerFormat": "Firebase ID token",
				"description": "See /auth.md and /.well-known/oauth-protected-resource (RFC 9728) — the real authorization server is Firebase Auth (https://securetoken.google.com/sportify-api), not onrun.com itself."
			}
		},
		"schemas": {
			"Event": {
				"type": "object",
				"properties": {
					"id": { "type": "string" },
					"name": { "type": "string" },
					"description": { "type": "string", "nullable": true },
					"city": { "type": "string", "nullable": true },
					"state": { "type": "string", "nullable": true },
					"type": { "type": "string", "nullable": true },
					"date": { "type": "string", "nullable": true },
					"endDate": { "type": "string", "nullable": true },
					"isFree": { "type": "boolean", "nullable": true },
					"priceCents": { "type": "integer", "nullable": true },
					"currency": { "type": "string", "nullable": true },
					"imageUrl": { "type": "string", "nullable": true },
					"organizer": { "type": "string", "nullable": true },
					"organizerLink": { "type": "string", "nullable": true },
					"isPrivate": { "type": "boolean", "nullable": true },
					"url": { "type": "string", "format": "uri" }
				},
				"required": ["id", "name", "url"]
			},
			"RaceHighlight": {
				"type": "object",
				"properties": {
					"eventName": { "type": "string" },
					"dateLabel": { "type": "string", "nullable": true },
					"result": { "type": "string", "nullable": true },
					"location": { "type": "string", "nullable": true },
					"rankLabel": { "type": "string", "nullable": true }
				},
				"required": ["eventName"]
			},
			"Runner": {
				"type": "object",
				"properties": {
					"id": { "type": "string" },
					"displayName": { "type": "string" },
					"handle": { "type": "string", "nullable": true },
					"city": { "type": "string", "nullable": true },
					"state": { "type": "string", "nullable": true },
					"photoURL": { "type": "string", "nullable": true },
					"url": { "type": "string", "format": "uri" }
				},
				"required": ["id", "displayName", "url"]
			},
			"Club": {
				"type": "object",
				"properties": {
					"id": { "type": "string" },
					"name": { "type": "string" },
					"city": { "type": "string", "nullable": true },
					"state": { "type": "string", "nullable": true }
				},
				"required": ["id", "name"]
			},
			"Me": {
				"type": "object",
				"properties": {
					"id": { "type": "string" },
					"displayName": { "type": "string", "nullable": true },
					"handle": { "type": "string", "nullable": true },
					"email": { "type": "string", "nullable": true },
					"isPrivate": { "type": "boolean" },
					"photoURL": { "type": "string", "nullable": true },
					"url": { "type": "string", "format": "uri" }
				},
				"required": ["id", "isPrivate", "url"]
			},
			"NotFound": {
				"type": "object",
				"properties": { "error": { "type": "string", "const": "not_found" } },
				"required": ["error"]
			},
			"AcpTotal": {
				"type": "object",
				"description": "One line of the ACP total breakdown. Amounts are in the currency's minor unit (cents); `items_discount` is negative.",
				"properties": {
					"type": { "type": "string", "enum": ["items_base_amount", "items_discount", "subtotal", "fulfillment", "tax", "total"] },
					"display_text": { "type": "string" },
					"amount": { "type": "integer" }
				},
				"required": ["type", "display_text", "amount"]
			},
			"AcpMessage": {
				"type": "object",
				"properties": {
					"type": { "type": "string", "enum": ["info", "error"] },
					"code": { "type": "string" },
					"severity": { "type": "string" },
					"resolution": { "type": "string" },
					"content_type": { "type": "string", "const": "plain" },
					"content": { "type": "string" }
				},
				"required": ["type", "content_type", "content"]
			},
			"AcpCheckoutSession": {
				"type": "object",
				"description": "ACP CheckoutSession. onRun sessions always carry exactly one line item at quantity 1 (one event entry) and a single digital fulfillment option.",
				"properties": {
					"id": { "type": "string", "description": "checkout_session_id — also the onRun reservation id." },
					"protocol": {
						"type": "object",
						"properties": { "name": { "type": "string", "const": "acp" }, "version": { "type": "string" } },
						"required": ["name", "version"]
					},
					"status": { "type": "string", "enum": ["ready_for_payment", "requires_escalation", "complete_in_progress", "completed", "canceled", "expired"] },
					"currency": { "type": "string" },
					"line_items": {
						"type": "array",
						"items": {
							"type": "object",
							"properties": {
								"id": { "type": "string" },
								"item": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "unit_amount": { "type": "integer" } }, "required": ["id"] },
								"quantity": { "type": "integer" },
								"totals": { "type": "array", "items": { "$ref": "#/components/schemas/AcpTotal" } }
							},
							"required": ["id", "item", "quantity", "totals"]
						}
					},
					"totals": { "type": "array", "items": { "$ref": "#/components/schemas/AcpTotal" } },
					"fulfillment_options": { "type": "array", "items": { "type": "object" } },
					"selected_fulfillment_options": { "type": "array", "items": { "type": "object" } },
					"messages": { "type": "array", "items": { "$ref": "#/components/schemas/AcpMessage" } },
					"links": { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string" }, "title": { "type": "string" }, "url": { "type": "string", "format": "uri" } }, "required": ["type", "url"] } },
					"capabilities": {
						"type": "object",
						"description": "Advertises onRun's own payment handler. `requires_delegate_payment` is false — this deployment does not accept Shared Payment Tokens.",
						"properties": { "payment": { "type": "object", "properties": { "handlers": { "type": "array", "items": { "type": "object" } } }, "required": ["handlers"] } },
						"required": ["payment"]
					},
					"continue_url": { "type": "string", "format": "uri", "description": "The onRun event page, where the buyer can confirm payment in a browser." },
					"created_at": { "type": "string", "format": "date-time" },
					"updated_at": { "type": "string", "format": "date-time" },
					"expires_at": { "type": "string", "format": "date-time", "description": "When the held spot is released." },
					"metadata": {
						"type": "object",
						"additionalProperties": { "type": "string" },
						"description": "`event_id`, `event_url`, `stripe_payment_intent_id`, and — only on the create response — `stripe_payment_intent_client_secret`."
					},
					"order": { "type": "object", "description": "Present only when `status` is `completed`." }
				},
				"required": ["id", "protocol", "status", "currency", "line_items", "totals", "messages", "links", "capabilities", "continue_url", "metadata"]
			},
			"AcpCheckoutSessionCreateRequest": {
				"type": "object",
				"properties": {
					"line_items": {
						"type": "array",
						"minItems": 1,
						"maxItems": 1,
						"description": "Exactly one item. `id` is the onRun event id.",
						"items": { "type": "object", "properties": { "id": { "type": "string" }, "quantity": { "type": "integer", "const": 1 } }, "required": ["id"] }
					},
					"currency": { "type": "string", "description": "Must match the event's currency." },
					"capabilities": { "type": "object", "description": "Required by the ACP create schema; onRun does not require any specific capability to be declared." },
					"discounts": {
						"type": "object",
						"properties": { "codes": { "type": "array", "maxItems": 1, "items": { "type": "string" } } },
						"description": "At most one code, evaluated by onRun's real perk/discount engine."
					}
				},
				"required": ["line_items", "currency", "capabilities"]
			},
			"AcpCheckoutSessionCompleteRequest": {
				"type": "object",
				"properties": {
					"payment_data": {
						"type": "object",
						"properties": {
							"handler_id": { "type": "string", "const": "onrun_stripe_delegated" },
							"instrument": {
								"type": "object",
								"properties": {
									"type": { "type": "string" },
									"credential": {
										"type": "object",
										"properties": {
											"type": { "type": "string", "const": "onrun_stripe_payment_intent", "description": "`spt` (Stripe Shared Payment Token) is rejected with 422 — onRun is not enrolled to receive one." },
											"token": { "type": "string", "description": "The Stripe PaymentIntent id from this session's `metadata.stripe_payment_intent_id`, after the buyer has confirmed it." }
										},
										"required": ["type", "token"]
									}
								},
								"required": ["credential"]
							}
						},
						"required": ["instrument"]
					}
				},
				"required": ["payment_data"]
			},
			"AcpError": {
				"type": "object",
				"description": "ACP error body.",
				"properties": {
					"type": { "type": "string", "enum": ["invalid_request", "processing_error", "service_unavailable"] },
					"code": { "type": "string" },
					"message": { "type": "string" },
					"param": { "type": "string", "description": "JSONPath to the offending field, when applicable." }
				},
				"required": ["type", "code", "message"]
			}
		}
	}
}
