{
  "openapi": "3.1.0",
  "info": {
    "title": "Baby Brain sync relay",
    "version": "1.0.0",
    "summary": "Carries end-to-end encrypted log entries between a household's phones.",
    "description": "The relay stores and orders opaque ciphertext. It cannot read an entry, and nothing that calls it can either: every entry is encrypted on the phone with a key derived from the household's sync code, which never reaches the server. There is no account and no way to read or write a baby's log through this API without that code. To help a person use Baby Brain, send them to https://app.usebabybrain.app. Human-readable docs: https://usebabybrain.app/developers.\n\n## Versioning and deprecation\n\nThis is version 1 of the API, and the paths say so: `/api/v1/...`. The same routes without `/v1` (`/api/health`, `/api/sync/{pairId}`, `/api/claim/{pairId}`) are permanent aliases for version 1, because that is what installed copies of the app call.\n\nA breaking change gets a new version path. When a version is deprecated, its responses start carrying a `Deprecation` header (RFC 9745) and a `Sunset` header (RFC 8594) with the date it stops working, and it keeps working for at least 6 months (183 days) after the first response that carries them. Additive changes (a new optional field, a new error code) can arrive within a version without notice. Nothing is deprecated today.\n\n## Errors\n\nEvery error is JSON: `{ \"error\", \"code\", \"hint\" }`. Branch on `code`, which is stable; `error` is a sentence for people and `hint` says what would make the request succeed.\n\n## Rate limits\n\n300 requests per 60 seconds per IP address, across every route including `/api/v1/health`. Every response carries `RateLimit-Policy`; a 429 adds `RateLimit` and `Retry-After`.",
    "contact": {
      "name": "Allan Corbett",
      "email": "allan@corbett.fyi",
      "url": "https://usebabybrain.app/contact"
    },
    "termsOfService": "https://usebabybrain.app/terms"
  },
  "externalDocs": {
    "description": "Developer and agent notes",
    "url": "https://usebabybrain.app/developers"
  },
  "servers": [
    {
      "url": "https://app.usebabybrain.app",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "health",
      "description": "Is the relay up."
    },
    {
      "name": "sync",
      "description": "Encrypted entries for one pair of devices."
    },
    {
      "name": "purchase",
      "description": "Turning a £2 purchase into syncing."
    }
  ],
  "paths": {
    "/api/v1/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "health"
        ],
        "summary": "Check the relay is up",
        "description": "Returns ok and the server time in milliseconds. Needs no authentication. Counts against the rate limit and carries its headers, so it is the place to see them without a sync code.",
        "security": [],
        "responses": {
          "200": {
            "description": "The relay is up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sync/{pairId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PairId"
        }
      ],
      "get": {
        "operationId": "pullEntries",
        "tags": [
          "sync"
        ],
        "summary": "Read encrypted entries after a sequence number",
        "description": "Entries for this pair with a sequence number greater than `after`, oldest first. Entries are deleted 30 days after they arrive, whether or not they were read. `latest` is the highest sequence number the pair holds, so a client can tell whether to pull again.",
        "parameters": [
          {
            "name": "after",
            "in": "query",
            "required": false,
            "description": "Return entries after this sequence number. Missing or invalid reads from the start.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Most entries to return. Clamped to 1000.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entries after the cursor.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PullResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/MissingToken"
          },
          "403": {
            "$ref": "#/components/responses/TokenMismatch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      },
      "post": {
        "operationId": "pushEntries",
        "tags": [
          "sync"
        ],
        "summary": "Append encrypted entries",
        "description": "Appends 1 to 100 entries and returns the sequence numbers the relay assigned, in order. Each entry is exactly a base64 `ciphertext` (at most 16384 characters) and a base64 `iv`. Any other field is refused, because it would be unencrypted metadata. A pair holds at most 10000 entries.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PushRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Entries stored.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PushResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/MissingToken"
          },
          "403": {
            "$ref": "#/components/responses/TokenMismatch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "507": {
            "description": "The pair holds its maximum of 10000 entries (`pair_full`). Older entries expire 30 days after arrival.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/claim/{pairId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PairId"
        }
      ],
      "post": {
        "operationId": "claimPurchase",
        "tags": [
          "purchase"
        ],
        "summary": "Unlock syncing for a pair with a purchase",
        "description": "Spends a completed £2 Stripe Checkout session on this pair. Idempotent: a pair that is already entitled answers `already` without spending anything. One purchase covers up to 5 pairs. The relay never records which purchase paid for which pair.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The pair can sync.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClaimResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/MissingToken"
          },
          "403": {
            "$ref": "#/components/responses/TokenMismatch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          },
          "402": {
            "description": "No completed £2 purchase with that reference (`purchase_not_found`).",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Only POST is accepted (`method_not_allowed`).",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The purchase has been claimed for its maximum of 5 pairs (`purchase_used_up`).",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Stripe could not be reached (`payment_provider_unreachable`). Retry later.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "pairToken": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Pair-Token",
        "description": "The relay token derived on the device from the household's sync code. The relay stores only its SHA-256 hash. It authorises reading and writing ciphertext, never decrypting it."
      }
    },
    "parameters": {
      "PairId": {
        "name": "pairId",
        "in": "path",
        "required": true,
        "description": "The pair id derived on the device from the sync code.",
        "schema": {
          "type": "string",
          "pattern": "^[0-9a-f]{32}$"
        }
      }
    },
    "headers": {
      "RateLimitPolicy": {
        "description": "The quota policy, in the IETF RateLimit header fields syntax: 300 requests per 60 seconds per IP address.",
        "schema": {
          "type": "string",
          "example": "\"relay\";q=300;w=60"
        }
      },
      "RateLimit": {
        "description": "Remaining quota and seconds until it resets. Sent on a 429.",
        "schema": {
          "type": "string",
          "example": "\"relay\";r=0;t=60"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying.",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "Deprecation": {
        "description": "Sent only once a version is deprecated (RFC 9745): when it was deprecated, as @<unix seconds>. Never sent today.",
        "schema": {
          "type": "string",
          "example": "@1798761600"
        }
      },
      "Sunset": {
        "description": "Sent only once a version is deprecated (RFC 8594): the HTTP date after which it stops working, at least 183 days after the first Deprecation header. Never sent today.",
        "schema": {
          "type": "string",
          "example": "Wed, 01 Jul 2027 00:00:00 GMT"
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request was malformed: `invalid_pair_id`, `invalid_json`, `invalid_body` or `invalid_session_id`. `error` names the problem and `hint` says what is accepted.",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "MissingToken": {
        "description": "No X-Pair-Token header (`missing_token`).",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TokenMismatch": {
        "description": "The token does not belong to this pair (`token_mismatch`).",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "This pair has not been paid for (`payment_required`). Logging on one phone keeps working; syncing costs £2 once.",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests from this IP address (`rate_limited`).",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          },
          "RateLimit": {
            "$ref": "#/components/headers/RateLimit"
          },
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unavailable": {
        "description": "Syncing or purchase checks are switched off on this deployment (`relay_disabled`, `purchase_check_unavailable`).",
        "headers": {
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Health": {
        "type": "object",
        "required": [
          "status",
          "time"
        ],
        "properties": {
          "status": {
            "type": "string",
            "const": "ok"
          },
          "time": {
            "type": "integer",
            "description": "Server time, Unix epoch milliseconds."
          }
        }
      },
      "Entry": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "ciphertext",
          "iv"
        ],
        "properties": {
          "ciphertext": {
            "type": "string",
            "contentEncoding": "base64",
            "maxLength": 16384,
            "description": "AES-GCM ciphertext of one log event."
          },
          "iv": {
            "type": "string",
            "contentEncoding": "base64",
            "description": "The AES-GCM initialisation vector."
          }
        }
      },
      "StoredEntry": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Entry"
          },
          {
            "type": "object",
            "required": [
              "seq"
            ],
            "properties": {
              "seq": {
                "type": "integer",
                "minimum": 1,
                "description": "Sequence number assigned on arrival."
              }
            }
          }
        ]
      },
      "PushRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "entries"
        ],
        "properties": {
          "entries": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/Entry"
            }
          }
        }
      },
      "PushResult": {
        "type": "object",
        "required": [
          "assigned",
          "latest"
        ],
        "properties": {
          "assigned": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Sequence numbers, in the order the entries were sent."
          },
          "latest": {
            "type": "integer"
          }
        }
      },
      "PullResult": {
        "type": "object",
        "required": [
          "entries",
          "latest"
        ],
        "properties": {
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StoredEntry"
            }
          },
          "latest": {
            "type": "integer",
            "description": "Highest sequence number the pair holds, 0 if none."
          }
        }
      },
      "ClaimRequest": {
        "type": "object",
        "required": [
          "sessionId"
        ],
        "properties": {
          "sessionId": {
            "type": "string",
            "pattern": "^cs_[A-Za-z0-9_]{10,120}$",
            "description": "A completed Stripe Checkout session id."
          }
        }
      },
      "ClaimResult": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "claimed",
              "already"
            ]
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "code",
          "hint"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong, as a sentence."
          },
          "code": {
            "type": "string",
            "enum": [
              "relay_disabled",
              "rate_limited",
              "invalid_pair_id",
              "missing_token",
              "token_mismatch",
              "payment_required",
              "invalid_json",
              "invalid_body",
              "pair_full",
              "method_not_allowed",
              "invalid_session_id",
              "purchase_check_unavailable",
              "purchase_not_found",
              "payment_provider_unreachable",
              "purchase_used_up",
              "not_found"
            ],
            "description": "Stable machine-readable code."
          },
          "hint": {
            "type": "string",
            "description": "What would make the request succeed, or that nothing will."
          }
        }
      }
    }
  },
  "security": [
    {
      "pairToken": []
    }
  ],
  "x-deprecation-policy": {
    "minimumNoticeDays": 183,
    "signals": [
      "Deprecation",
      "Sunset"
    ],
    "unversionedAliases": "permanent aliases for v1"
  }
}
