diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5561a331..e344bbc6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,4 +10,5 @@ jobs:
- name: Lychee Broken Link Checker
uses: lycheeverse/lychee-action@v2.9.0
with:
+ args: --root-dir "$(pwd)/src/content/docs" --verbose --no-progress './**/*.md' './**/*.mdx'
fail: true
diff --git a/.vale.ini b/.vale.ini
index 7f70c7fc..a6c91592 100644
--- a/.vale.ini
+++ b/.vale.ini
@@ -1,6 +1,8 @@
StylesPath = styles
MinAlertLevel = error
+Vocab = OPA
+
Packages = Google, write-good, MDX
[*.{md,mdx}]
diff --git a/astro.config.ts b/astro.config.ts
index ae8e0c73..18d957f2 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -1,8 +1,18 @@
import { defineConfig } from "astro/config";
+import { satteri } from '@astrojs/markdown-satteri';
import starlight from "@astrojs/starlight";
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi'
export default defineConfig({
+ markdown: {
+ processor: satteri({
+ features: {
+ gfm: true,
+ headingAttributes: true,
+ directive: true,
+ },
+ })
+ },
integrations: [
starlight({
title: "Open Podcast API",
@@ -41,14 +51,29 @@ export default defineConfig({
label: "Introduction",
link: "specs",
},
+ {
+ label: "Conventions",
+ link: "specs/conventions"
+ },
+ {
+ label: "Authentication",
+ link: "specs/authentication"
+ },
+ {
+ label: "Synchronization",
+ link: "specs/sync",
+ badge: {
+ text: "Core",
+ variant: "success"
+ }
+ },
{
label: "Subscriptions",
- collapsed: true,
- items: [{
- autogenerate: {
- directory: "specs/subscriptions",
- }
- }],
+ link: "specs/subscriptions",
+ badge: {
+ text: 'Core',
+ variant: 'success'
+ }
},
],
},
diff --git a/schema.yml b/schema.yml
index 13205552..6bddb806 100644
--- a/schema.yml
+++ b/schema.yml
@@ -1,607 +1,611 @@
-openapi: 3.0.3
+openapi: 3.1.0
info:
- title: OpenPodcast API
- description: |-
- This is the OpenAPI specification for the OpenPodcast API
+ title: Open Podcast API
+ description: |
+ This is the OpenAPI specification for the Open Podcast API. For more information, check the specs at .
version: 0.1.0
tags:
+ - name: Authentication
+ description: All actions relating to authentication
+ - name: Synchronization
+ description: The core synchronization endpoint
- name: Subscriptions
- description: All actions relating to subscription management
+ description: Endpoints relating to podcast subscriptions
paths:
- /subscriptions:
- get:
+ /auth/login:
+ post:
tags:
- - Subscriptions
- summary: Retrieve all updated subscription information
- description: Retrieve all subscription information that has changed since the provided timestamp
- operationId: getSubscriptions
- parameters:
- - in: query
- name: since
- schema:
- type: string
- format: date-time
- required: false
- example:
- '2022-04-23T18:25:43.511Z'
- - in: query
- name: page
- schema:
- type: number
- required: false
- example:
- 1
- - in: query
- name: per_page
- schema:
- type: number
- required: false
- example:
- 5
+ - Authentication
+ summary: Log in to the server
+ description: |
+ Logs a user in to the server and provides access credentials for the requesting client
+ operationId: login
+ requestBody:
+ $ref: '#/components/requestBodies/Login'
responses:
'200':
- description: Successful operation
+ description: Successful login
content:
application/json:
schema:
- $ref: '#/components/schemas/Subscriptions'
- application/xml:
- schema:
- $ref: '#/components/schemas/Subscriptions'
+ $ref: '#/components/schemas/LoginResponse'
'401':
- $ref: '#/components/responses/Unauthorized'
- security:
- - podcast_auth:
- - read:subscriptions
+ $ref: '#/components/responses/NotAuthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /auth/refresh:
post:
tags:
- - Subscriptions
- summary: Add new subscriptions for the authenticated user
- description: Add one or more new subscriptions for the authenticated user by passing an array of feed URLs in the request body
- operationId: addSubscription
+ - Authentication
+ summary: Refresh an access token
+ description: |
+ Generates a new access token using an existing valid refresh token
+ operationId: refreshToken
requestBody:
- $ref: '#/components/requestBodies/FeedArray'
+ $ref: '#/components/requestBodies/RefreshToken'
responses:
'200':
- description: Successful operation
+ description: Successful token refresh
content:
application/json:
schema:
- $ref: '#/components/schemas/NewSubscriptions'
- application/xml:
- schema:
- $ref: '#/components/schemas/NewSubscriptions'
+ $ref: '#/components/schemas/AccessTokenResponse'
'401':
- $ref: '#/components/responses/Unauthorized'
- '405':
- $ref: '#/components/responses/ValidationException'
- security:
- - podcast_auth:
- - write:subscriptions
- /subscriptions/{guid}:
+ description: Refresh token is expired
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Unauthorized
+ description: The supplied refresh token has expired. Please reauthenticate
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /api/v1/sync:
get:
tags:
- - Subscriptions
- summary: Retrieve information about a single subscription
- description: Retrieve information about a single subscription specified in the path
- operationId: getSubscription
+ - Synchronization
+ summary: Sync from the server
+ description: Fetch actions taken since the specified last sync time
+ security:
+ - bearerAuth: []
+ operationId: fetchSync
parameters:
- - in: path
- name: guid
+ - name: since
+ in: query
+ description: The most recent update timestamp to fetch data from
schema:
type: string
- format: guid
- required: true
- example: 968cb508-803c-493c-8ff2-9e397dadb83c
+ format: date-time
+ - name: limit
+ description: The number of results to include per page
+ in: query
+ schema:
+ type: number
+ format: int64
+ default: 50
+ - name: include
+ in: query
+ description: |
+ The entities to include in the response. Defaults to `all`
+ schema:
+ type: string
+ enum:
+ - all
+ - subscriptions
+ default: all
+ - name: include_own
+ in: query
+ description: |
+ Whether to include updates made by the requesting device. Defaults to `false`
+ schema:
+ type: boolean
+ default: false
responses:
'200':
- description: Successful operation
+ description: Sync requested successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Subscription'
- application/xml:
- schema:
- $ref: '#/components/schemas/Subscription'
+ type: object
+ required:
+ - results
+ properties:
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/ActionObject'
'401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- '405':
- $ref: '#/components/responses/ValidationException'
- '410':
- $ref: '#/components/responses/Gone'
- security:
- - podcast_auth:
- - read:subscriptions
- patch:
+ description: Access token is invalid
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Unauthorized
+ description: The supplied access token is invalid
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ post:
tags:
- - Subscriptions
- summary: Update a single subscription entry
- description: Update information about a single subscription specified in the path
- operationId: updateSubscription
+ - Synchronization
+ operationId: postSync
+ description: |
+ Post up to 30 actions per batch
+ security:
+ - bearerAuth: []
parameters:
- - in: path
- name: guid
+ - name: Client-ID
+ in: header
+ description: A unique identifier generated by the client for excluding results
schema:
type: string
- format: guid
- required: true
- example: 968cb508-803c-493c-8ff2-9e397dadb83c
+ format: uuid
requestBody:
- $ref: '#/components/requestBodies/PatchedSubscription'
+ summary: Sync request
+ description: The array of actions sent to the client
+ $ref: '#/components/requestBodies/SyncAction'
responses:
'200':
- description: Successful operation
+ description: Actions handled
content:
application/json:
schema:
- $ref: '#/components/schemas/PatchedSubscription'
- application/xml:
+ $ref: '#/components/schemas/SyncActionResponse'
+ '400':
+ description: Bad request
+ content:
+ application/json:
schema:
- $ref: '#/components/schemas/PatchedSubscription'
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Bad request
+ description: The request payload is invalid. Check the number of entries.
'401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- '405':
- $ref: '#/components/responses/ValidationException'
- security:
- - podcast_auth:
- - write:subscriptions
- delete:
+ description: Access token is invalid
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Unauthorized
+ description: The supplied access token is invalid
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /api/v1/subscriptions:
+ get:
tags:
- Subscriptions
- summary: Delete a single subscription entry
- description: Deletes a single subscription specified in the path
- operationId: deleteSubscription
+ operationId: getSubscriptions
+ security:
+ - bearerAuth: []
parameters:
- - in: path
- name: guid
+ - name: include_unsubscribed
+ description: Whether to include unsubscribed feeds in the response
+ in: query
+ schema:
+ type: boolean
+ default: false
+ - name: since
+ description: The cursor position to start at
+ in: query
schema:
type: string
- format: guid
- required: true
- example: 2d8bb39b-8d34-48d4-b223-a0d01eb27d71
- responses:
- '202':
- $ref: '#/components/responses/DeletionReceived'
+ - name: limit
+ description: The number of results to include per page
+ in: query
+ schema:
+ type: number
+ format: int64
+ default: 50
+ responses:
+ '200':
+ description: Subscriptions found
+ content:
+ application/json:
+ schema:
+ allOf:
+ - type: object
+ required:
+ - results
+ properties:
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/PersistedSubscription'
+ - $ref: '#/components/schemas/Pagination'
+ examples:
+ - results:
+ - sync_id: 4648b4d7-90bf-497c-a5e7-8383d1083d76
+ guid: 677ea490-690e-51cb-8b43-755df6c55270
+ feed_url: https://example.com/feed1
+ subscribed_at: 2026-07-11T15:30:00.1Z
+ - sync_id: aa840671-fc3b-430b-b8f2-92e6e9c7832b
+ guid: a388867e-ce91-54d3-a116-114b07bb84e9
+ feed_url: https://example.com/feed2
+ subscribed_at: 2026-03-15T12:06:04.1Z
+ unsubscribed_at: 2026-07-12T12:10:00.1Z
+ has_next: true
+ next_cursor: /api/v1/subscriptions?since=3
'401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- '405':
- $ref: '#/components/responses/ValidationException'
- security:
- - podcast_auth:
- - write:subscriptions
- /deletions/{id}:
+ description: Access token is invalid
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Unauthorized
+ description: The supplied access token is invalid
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /api/v1/subscriptions/{sync_id}:
get:
tags:
- Subscriptions
- summary: Retrieve information subscription deletion
- description: Retrieve information about a deletion by querying the ID sent in a deletion request
- operationId: getDeletions
+ operationId: getSubscription
+ security:
+ - bearerAuth: []
parameters:
- - in: path
- name: id
+ - name: sync_id
+ in: path
+ description: The unique sync ID of the subscription
schema:
- type: number
- format: integer
+ type: string
+ format: uuid
required: true
- example: 25
responses:
'200':
- $ref: '#/components/responses/DeletionResponse'
+ description: Subscription found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PersistedSubscription'
'401':
- $ref: '#/components/responses/Unauthorized'
+ description: Access token is invalid
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Unauthorized
+ description: The supplied access token is invalid
'404':
- $ref: '#/components/responses/NotFound'
- '405':
- $ref: '#/components/responses/ValidationException'
- security:
- - podcast_auth:
- - read:subscriptions
+ description: Subscription not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Not found
+ description: The requested subscription was not found
+ '500':
+ $ref: '#/components/responses/InternalServerError'
components:
- responses:
- Unauthorized:
- description: Unauthorized
+ requestBodies:
+ Login:
+ description: A login request payload
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 401
- message: User not authorized
- application/xml:
- schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 401
- message: User not authorized
- NotFound:
- description: Not found
+ $ref: '#/components/schemas/LoginRequest'
+ RefreshToken:
+ description: A refresh token request
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 404
- message: Resource not found
- application/xml:
- schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 404
- message: Resource not found
- ValidationException:
- description: Validation exception
+ $ref: '#/components/schemas/RefreshTokenRequest'
+ SyncAction:
+ description: A sync action
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 405
- message: Input could not be validated
- application/xml:
- schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 405
- message: Input could not be validated
- Gone:
- description: Object deleted
+ type: object
+ required:
+ - requests
+ properties:
+ requests:
+ type: array
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/SubscriptionAction'
+ discriminator:
+ propertyName: type
+ mapping:
+ subscription: '#/components/schemas/SubscriptionAction'
+ responses:
+ NotAuthorized:
+ description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
- code: 410
- message: Subscription has been deleted
- application/xml:
- schema:
- $ref: '#/components/schemas/Error'
- example:
- code: 410
- message: Subscription has been deleted
- DeletionReceived:
- description: The deletion request has been received and will be processed
+ error: Unauthorized
+ description: The username or password is incorrect
+ InternalServerError:
+ description: Internal server error
content:
application/json:
schema:
- $ref: '#/components/schemas/Success'
- example:
- deletion_id: 25
- message: Deletion request was received and will be processed
- application/xml:
- schema:
- $ref: '#/components/schemas/Success'
+ $ref: '#/components/schemas/Error'
example:
- deletion_id: 25
- message: Deletion request was received and will be processed
- DeletionResponse:
- description: The object was successfully deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Deletion'
- examples:
- Success:
- value:
- deletion_id: 25
- status: SUCCESS
- message: Subscription deleted successfully
- Pending:
- value:
- deletion_id: 25
- status: PENDING
- message: Deletion is pending
- Failure:
- value:
- deletion_id: 25
- status: FAILURE
- message: The deletion process encountered an error and was rolled backwas rolled back
- application/xml:
- schema:
- $ref: '#/components/schemas/Deletion'
- examples:
- Success:
- value:
- deletion_id: 25
- status: SUCCESS
- message: Subscription deleted successfully
- Pending:
- value:
- deletion_id: 25
- status: PENDING
- message: Deletion is pending
- Failure:
- value:
- deletion_id: 25
- status: FAILURE
- message: The deletion process encountered an error and was rolled back
+ error: Internal server error
+ description: The server encountered an error while processing the request
schemas:
Error:
+ description: Generic error object
type: object
+ required:
+ - error
+ - description
properties:
- code:
+ error:
type: string
- message:
+ description: The error message
+ description:
type: string
- required:
- - code
- - message
- Success:
+ description: The error description
+ Pagination:
+ description: Pagination properties for paginated results
type: object
- properties:
- deletion_id:
- type: number
- format: integer
- message:
- type: string
- required:
- - code
- - message
- Subscription:
- xml:
- name: subscription
required:
- - feed_url
- - guid
- - is_subscribed
- type: object
+ - has_next
properties:
- feed_url:
- type: string
- format: url
- guid:
- type: string
- format: guid
- is_subscribed:
+ has_next:
type: boolean
- subscription_changed:
- type: string
- format: date-time
- new_guid:
+ description: Whether there is a next page of results
+ next_cursor:
type: string
- format: guid
- guid_changed:
- type: string
- format: date-time
- deleted:
+ format: url
+ description: The URL of the next page of results, if available
+ prev_cursor:
type: string
- format: date-time
- example:
- feed_url: https://example.com/feed2
- guid: 968cb508-803c-493c-8ff2-9e397dadb83c
- is_subscribed: true
- NewSubscription:
- xml:
- name: success
- required:
- - feed_url
- - guid
- - is_subscribed
- - subscription_changed
+ format: url
+ description: The URL of the previous page of results, if available
+ LoginResponse:
+ description: A response object containing an access token and refresh token
+ allOf:
+ - $ref: '#/components/schemas/AccessTokenResponse'
+ - $ref: '#/components/schemas/RefreshTokenResponse'
+ examples:
+ - access_token: G6IWgOSDfXylCr8donVs5wlnfaBvv6EszuKcppz9XXbC9AqiT5jp161nN05jghcQ2OQ/JMRvSaUQxf6L9fWVYA==
+ expires_in: 3600
+ refresh_token: vRT0XmbTHs3KzrPZJD8F92AWnOrPk9o781mQkP63iyZZ8MNzfpd5x5xVRojFrjXmM72vYe3I9v/PR16dzHxgkg==
+ refresh_expires: 2026-07-11T15:30:00Z
+ refresh_endpoint: /auth/refresh
+ LoginRequest:
type: object
+ description: A login request payload
+ required:
+ - username
+ - password
properties:
- feed_url:
+ username:
type: string
- format: url
- guid:
- type: string
- format: guid
- is_subscribed:
- type: boolean
- subscription_changed:
+ description: The username of the requesting user
+ password:
type: string
- format: date-time
- FailedSubscription:
- xml:
- name: failure
- required:
- - feed_url
- - message
+ description: The password of the requesting user
+ examples:
+ - username: example-user
+ password: example-password
+ AccessTokenResponse:
type: object
+ description: A response object containing a refreshed access token
+ required:
+ - access_token
+ - expires_in
properties:
- feed_url:
- type: string
- format: url
- message:
+ access_token:
type: string
- Subscriptions:
+ description: The access token generated by the server
+ expires_in:
+ type: number
+ description: The lifetime of the access token, in seconds
+ examples:
+ - access_token: G6IWgOSDfXylCr8donVs5wlnfaBvv6EszuKcppz9XXbC9AqiT5jp161nN05jghcQ2OQ/JMRvSaUQxf6L9fWVYA==
+ expires_in: 3600
+ RefreshTokenRequest:
+ type: object
+ description: A refresh token request
required:
- - total
- - page
- - per_page
- - subscriptions
- xml:
- name: subscriptions
+ - refresh_token
+ properties:
+ refresh_token:
+ type: string
+ description: The refresh token held by the client
+ examples:
+ - refresh_token: vRT0XmbTHs3KzrPZJD8F92AWnOrPk9o781mQkP63iyZZ8MNzfpd5x5xVRojFrjXmM72vYe3I9v/PR16dzHxgkg==
+ RefreshTokenResponse:
type: object
+ description: A response object containing a refresh token
+ required:
+ - refresh_token
+ - refresh_expires
+ - refresh_endpoint
properties:
- total:
- type: number
- page:
- type: number
- per_page:
- type: number
- next:
+ refresh_token:
type: string
- format: url
- previous:
+ description: The refresh token generated by the server
+ refresh_expires:
+ type: string
+ format: date-time
+ description: The UTC timestamp at which the refresh token expires
+ refresh_endpoint:
type: string
format: url
- subscriptions:
- type: array
- items:
- $ref: '#/components/schemas/Subscription'
- example:
- total: 2
- page: 1
- per_page: 5
- subscriptions:
- - feed_url: https://example.com/rss1
- guid: 31740ac6-e39d-49cd-9179-634bcecf4143
- is_subscribed: true
- guid_changed: 2022-09-21T10:25:32.411Z
- new_guid: 8d1f8f09-4f50-4327-9a63-639bfb1cbd98
- - feed_url: https://example.com/rss2
- guid: 968cb508-803c-493c-8ff2-9e397dadb83c
- is_subscribed: false
- subscription_changed: 2022-04-24T17:53:21.573Z
- deleted: 2022-04-24T17:53:21.573Z
- NewSubscriptions:
- xml:
- name: subscriptions
+ description: The endpoint used to refresh the access token
+ examples:
+ - refresh_token: vRT0XmbTHs3KzrPZJD8F92AWnOrPk9o781mQkP63iyZZ8MNzfpd5x5xVRojFrjXmM72vYe3I9v/PR16dzHxgkg==
+ refresh_expires: 2026-07-11T15:30:00Z
+ refresh_endpoint: /auth/refresh
+ EntityType:
+ type: string
+ description: The type of entity targeted by an action
+ enum:
+ - subscription
+ ActionObject:
type: object
- properties:
- success:
- type: array
- items:
- $ref: '#/components/schemas/NewSubscription'
- failure:
- type: array
- items:
- $ref: '#/components/schemas/FailedSubscription'
- example:
- success:
- - feed_url: https://example.com/rss1
- guid: 8d1f8f09-4f50-4327-9a63-639bfb1cbd98
- is_subscribed: true
- subscription_changed: 2023-02-23T14:00:00.000Z
- - feed_url: https://example.com/rss2
- guid: 968cb508-803c-493c-8ff2-9e397dadb83c
- is_subscribed: true
- subscription_changed: 2023-02-23T14:00:00.000Z
- - feed_url: https://example.com/rss3
- guid: e672c1f4-230d-4ab4-99d3-390a9f835ec1
- is_subscribed: true
- subscription_changed: 2023-02-23T14:00:00.000Z
- failure:
- - feed_url: example.com/rss4
- message: No protocol present
- FeedArray:
- xml:
- name: subscriptions
+ description: An object containing action information
required:
- - subscriptions
- type: object
- properties:
- subscriptions:
- xml:
- name: subscription
- type: array
- items:
- type: object
- required:
- - feed_url
- properties:
- feed_url:
- type: string
- guid:
- type: string
- example:
- - feed_url: https://example.com/feed1
- - feed_url: https://example.com/feed2
- - feed_url: https://example.com/feed3
- - feed_url: example.com/feed4
- guid: 2d8bb39b-8d34-48d4-b223-a0d01eb27d71
- SubscriptionUpdate:
- xml:
- name: subscription
- type: object
- minProperties: 1
+ - type
+ - action
+ - action_id
+ - occurred_at
+ - data
properties:
- new_feed_url:
+ type:
+ $ref: '#/components/schemas/EntityType'
+ action:
type: string
- format: url
- new_guid:
+ description: The action being taken on the target
+ action_id:
type: string
format: uuid
- is_subscribed:
- type: boolean
- example:
- new_feed_url: https://example.com/rss5
- new_guid: 965fcecf-ce04-482b-b57c-3119b866cc61
- is_subscribed: false
- PatchedSubscription:
- xml:
- name: subscription
+ description: A client-generated UUID identifier for the action
+ occurred_at:
+ type: string
+ format: date-time
+ description: The UTC timestamp of when the action was undertaken on the client
+ data:
+ anyOf:
+ - $ref: '#/components/schemas/Subscription'
+ Subscription:
type: object
- minProperties: 1
+ required:
+ - guid
+ - feed_url
+ - subscribed_at
properties:
- new_feed_url:
+ sync_id:
+ type: string
+ format: uuid
+ description: The server-generated synchronization ID for the subscription
+ guid:
+ type: string
+ description: |
+ The `guid` field as found in the podcast's RSS feed
+ feed_url:
type: string
format: url
- is_subscribed:
- type: boolean
- subscription_changed:
+ description: The canonical address of the podcast RSS feed
+ subscribed_at:
type: string
format: date-time
- new_guid:
+ description: The UTC timestamp at which the user subscribed to the feed
+ unsubscribed_at:
type: string
- format: uuid
- guid_changed:
+ format: date-time
+ description: The UTC timestamp at which the user unsubscribed from the feed
+ examples:
+ - sync_id: 4648b4d7-90bf-497c-a5e7-8383d1083d76
+ guid: 677ea490-690e-51cb-8b43-755df6c55270
+ feed_url: https://example.com/feed1
+ subscribed_at: 2026-07-11T15:30:00.1Z
+ - sync_id: aa840671-fc3b-430b-b8f2-92e6e9c7832b
+ guid: a388867e-ce91-54d3-a116-114b07bb84e9
+ feed_url: https://example.com/feed2
+ subscribed_at: 2026-03-15T12:06:04.1Z
+ unsubscribed_at: 2026-07-12T12:10:00.1Z
+ PersistedSubscription:
+ allOf:
+ - $ref: '#/components/schemas/Subscription'
+ - type: object
+ required:
+ - sync_id
+ ActionId:
+ type: string
+ format: uuid
+ description: The client-generated UUIDv4 identifier for the action
+ SyncAction:
+ type: object
+ required:
+ - action_id
+ - occurred_at
+ - type
+ - action
+ - data
+ properties:
+ action_id:
+ $ref: '#/components/schemas/ActionId'
+ occurred_at:
type: string
format: date-time
- example:
- new_feed_url: https://example.com/rss5
- is_subscribed: false
- subscription_changed: 2023-02-23T14:41:00.000Z
- guid_changed: 2023-02-23T14:41:00.000Z
- new_guid: 965fcecf-ce04-482b-b57c-3119b866cc61
- Deletion:
- xml:
- name: deletion
+ description: The UTC timestamp at which the action occurred on the client
+ type:
+ type: string
+ action:
+ type: string
+ data:
+ type: object
+ SubscriptionActions:
+ type: string
+ enum:
+ - subscribe
+ - unsubscribe
+ - update
+ SubscriptionAction:
+ allOf:
+ - $ref: '#/components/schemas/SyncAction'
+ - type: object
+ properties:
+ type:
+ type: string
+ enum: [subscription]
+ action:
+ $ref: '#/components/schemas/SubscriptionActions'
+ data:
+ $ref: '#/components/schemas/Subscription'
+ SyncActionResult:
+ type: object
required:
- - deletion_id
+ - action_id
+ - type
+ - action
- status
- type: object
properties:
- deletion_id:
- type: number
- format: integer
- status:
+ action_id:
+ $ref: '#/components/schemas/ActionId'
+ type:
type: string
- enum:
- - SUCCESS
- - FAILURE
- - PENDING
- message:
+ description: The target entity type
+ action:
type: string
- example:
- deletion_id: 25
- status: SUCCESS
- message: Subscription deleted successfully
- requestBodies:
- FeedArray:
- description: An array of feeds the user wants to subscribe to
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/FeedArray'
- application/xml:
- schema:
- $ref: '#/components/schemas/FeedArray'
- PatchedSubscription:
- description: A request containing new information to update an existing subscription with
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/SubscriptionUpdate'
- application/xml:
- schema:
- $ref: '#/components/schemas/SubscriptionUpdate'
+ description: The action being taken on the target
+ status:
+ type: string
+ enum: [applied, ignored, rejected]
+ SubscriptionActionResult:
+ allOf:
+ - $ref: '#/components/schemas/SyncActionResult'
+ - type: object
+ properties:
+ type:
+ type: string
+ enum: [subscription]
+ action:
+ $ref: '#/components/schemas/SubscriptionActions'
+ data:
+ $ref: '#/components/schemas/PersistedSubscription'
+
+ SyncActionResponse:
+ type: object
+ required:
+ - results
+ properties:
+ results:
+ type: array
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/SubscriptionActionResult'
+ # - $ref: '#/components/schemas/EpisodeActionResult'
+ discriminator:
+ propertyName: type
+ mapping:
+ subscription: '#/components/schemas/SubscriptionActionResult'
+ # episode: '#/components/schemas/EpisodeActionResult'
securitySchemes:
- podcast_auth:
- type: oauth2
- flows:
- implicit:
- authorizationUrl: https://test.openpodcastapi.com/oauth/authorize
- scopes:
- write:subscriptions: modify subscription information for your account
- read:subscriptions: read your subscription information
- api_key:
- type: apiKey
- name: api_key
- in: header
+ bearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
\ No newline at end of file
diff --git a/src/content/docs/partials/_core-action.mdx b/src/content/docs/partials/_core-action.mdx
deleted file mode 100644
index cc3c862b..00000000
--- a/src/content/docs/partials/_core-action.mdx
+++ /dev/null
@@ -1,3 +0,0 @@
-:::caution[Core action]
-This is a **core action**. All server implementations MUST support it.
-:::
diff --git a/src/content/docs/partials/_core-endpoint.mdx b/src/content/docs/partials/_core-endpoint.mdx
deleted file mode 100644
index d832eefb..00000000
--- a/src/content/docs/partials/_core-endpoint.mdx
+++ /dev/null
@@ -1,3 +0,0 @@
-:::caution[Core endpoint]
-This is a **core endpoint**. All server implementations MUST support it.
-:::
diff --git a/src/content/docs/specs/authentication.md b/src/content/docs/specs/authentication.md
new file mode 100644
index 00000000..0b99a2c6
--- /dev/null
+++ b/src/content/docs/specs/authentication.md
@@ -0,0 +1,130 @@
+---
+title: Authentication
+description: The Open Podcast API uses Bearer authentication for all protected endpoints.
+---
+
+The Open Podcast API uses a bearer token authentication model based on a simplified OAuth 2.0-style login flow.
+
+Clients authenticate with a username and password to obtain an access token and a refresh token. The access token is used to authorize API requests. When the access token expires, the client should use the refresh token to obtain a new access token. If the refresh token has expired, the user must authenticate again.
+
+## Authentication flow {#authentication-flow}
+
+1. The client submits the user's credentials to the `/auth/login` endpoint.
+1. The server validates the credentials and returns:
+ * An access token
+ * The access token expiration offset (in seconds)
+ * A refresh token
+ * The refresh token expiration time
+ * The endpoint used to refresh the access token
+1. The client includes the access token in the `Authorization` header for all authenticated requests.
+1. When the access token expires, the client requests a new access token using the refresh token.
+1. If the refresh token has expired or is no longer valid, the client must authenticate again using the `/auth/login` endpoint.
+
+## Login {#login}
+
+```txt title="Endpoint"
+POST /auth/login
+```
+
+### Request {#login-request}
+
+```sh
+curl '/auth/login' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "username": "user@example.com",
+ "password": "your-password"
+ }'
+```
+
+#### Successful response {#login-success-response}
+
+```json title="HTTP 200 OK"
+{
+ "access_token": "",
+ "expires_in": 3600,
+ "refresh_token": "",
+ "refresh_expires": "2026-07-11T15:30:00Z",
+ "refresh_endpoint": "/auth/refresh"
+}
+```
+
+#### Response fields {#login-success-response-fields}
+
+| Field | Description |
+|--------------------|----------------------------------------------|
+| `access_token` | Token used to authenticate API requests |
+| `expires_in` | Lifetime of the access token in seconds |
+| `refresh_token` | Token used to obtain a new access token |
+| `refresh_expires` | UTC timestamp when the refresh token expires |
+| `refresh_endpoint` | Endpoint used to refresh the access token |
+
+## Refreshing an access token {#refreshing-access-token}
+
+When the access token expires, the client should request a new one before making additional API calls.
+
+```txt title="Endpoint"
+POST /auth/refresh
+```
+
+### Request {#refresh-request}
+
+```sh
+curl '/auth/refresh' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "refresh_token": ""
+ }'
+```
+
+```json title="Successful response"
+{
+ "access_token": "",
+ "expires_in": 3600
+}
+```
+
+If the refresh token is expired or invalid, the server must return an `401 Unauthorized` error. The client must then authenticate again using the `/auth/login` endpoint.
+
+## Using the Access Token {#use-access-token}
+
+All authenticated requests must include the access token in the `Authorization` header using the Bearer authentication scheme.
+
+### Example {#use-token-example}
+
+```sh title="Example request"
+curl '/api/v1/sync \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+```
+
+## Token Lifecycle {#token-lifecycle}
+
+| State | Client Action |
+|-----------------------------------------|-----------------------------------------------------|
+| Access token is valid | Continue using the access token. |
+| Access token has expired | Request a new access token using the refresh token. |
+| Refresh token has expired or is invalid | Authenticate again using the `/login` endpoint. |
+
+
+## Error Responses {#error-responses}
+
+| HTTP Status | Description |
+|-----------------------------|------------------------------------------------------------------------------|
+| `400 Bad Request` | Request is malformed or required fields are missing. |
+| `401 Unauthorized` | Credentials or tokens are invalid or expired. |
+| `403 Forbidden` | The authenticated user is not authorized to perform the requested operation. |
+| `500 Internal Server Error` | An unexpected server error occurred. |
+
+## Security Considerations {#security-considerations}
+
+* Always use HTTPS to protect credentials and tokens in transit.
+* Store access and refresh tokens securely.
+* Never expose tokens in URLs or log files.
+* Send the access token only in the Authorization header.
+* Treat refresh tokens as sensitive credentials and protect them accordingly.
diff --git a/src/content/docs/specs/conventions.md b/src/content/docs/specs/conventions.md
new file mode 100644
index 00000000..dc8e3b66
--- /dev/null
+++ b/src/content/docs/specs/conventions.md
@@ -0,0 +1,108 @@
+---
+title: Conventions
+description: All endpoints in the Open Podcast API follow the conventions outlined in this document.
+---
+
+The following conventions are used for all endpoints in the Open Podcast API specification.
+
+## Content type {#content-type}
+
+All endpoints covered by the Open Podcast API require a `Content-Type` header to request JSON information from the server.
+
+```txt
+Content-Type: application/json
+```
+
+## Client IDs {#client-ids}
+
+Each client must generate and persist a [UUIDv4](https://www.rfc-editor.org/rfc/rfc9562.html#name-uuid-version-4) value that uniquely identifies that client installation or instance. Client IDs identify a client installation rather than a user or device.
+
+Include this value with every API request using the `Client-ID` header.
+
+```txt
+Client-ID: 550e8400-e29b-41d4-a716-446655440000
+```
+
+:::note
+The client ID is used only to identify the source of synchronized changes. It is not an authentication credential and can be freely regenerated if the client state is reset.
+:::
+
+### Requirements {#client-id-requirements}
+
+* Generate the ID once, when the client is first initialized.
+* The ID must be a valid UUID version 4 (random UUID).
+* Persist the ID and reuse it for all future requests.
+* Do not generate a new ID for each request.
+* Each independent client installation or instance should have its own unique ID.
+
+### Purpose {#client-id-purpose}
+
+The server uses the client ID to identify the origin of changes. Endpoints that return updates can exclude changes created by the requesting client, allowing clients to synchronize only updates originating from other clients.
+
+## Timestamps {#timestamps}
+
+:::caution[Important]
+The synchronization protocol assumes that client clocks are reasonably accurate. Devices with significantly incorrect clocks may observe unexpected synchronization order.
+:::
+
+All actions sent to the server must include a UTC timestamp recorded by the client. These timestamps determine action order. The server compares the client-supplied timestamp with the entity's current modification timestamp. An action with a later timestamp MUST be applied. An action with an earlier timestamp MUST be ignored.
+
+Timestamps must be submitted in the [RFC3339 format](https://www.rfc-editor.org/rfc/rfc3339). Servers must accept any fractional precision. For example:
+
+* `2026-07-11T15:30:00Z`
+* `2026-07-11T15:30:00.1Z`
+* `2026-07-11T15:30:00.123456Z`
+
+Once generated, an action's timestamp must not be modified. Retried requests must preserve the original timestamp.
+
+Actions contained within a batch are independent. Servers must order actions solely according to their timestamps rather than the order in which they appear in the request.
+
+An action is applied only if its timestamp is strictly later than the entity's current `last_updated` timestamp. Actions with an earlier or identical timestamp are ignored.
+
+## Synchronization model {#sync-model}
+
+The Open Podcast API follows a batch synchronization model through a [synchronization endpoint](/specs/sync). Updates are processed in bulk using action names and entity types as discriminators. To reduce the risk of performance degradation while facilitating bulk updates from clients, updates are limited to 30 actions per request.
+
+### Client behavior {#client-sync-behavior}
+
+Synchronization of data is based on client timestamps. These are considered authoritative. When a user performs an action on a client, the client must record the UTC timestamp of the action and send it with the corresponding update action.
+
+The client may send multiple bulk requests in sequence, starting from the earliest recorded timestamp. Requests are expected to be sent in chronological order.
+
+### Server behavior {#server-sync-behavior}
+
+The server modifies state based on a last-write-wins model where the action with the latest timestamp always applies.
+
+The server must disregard any actions with a timestamp earlier than the current modification timestamp as stale. The server must always return the current canonical state of the requested entity whether the action was applied or not.
+
+Clients may request information from a given offset timestamp to fetch all relevant updates that have occurred since their last sync. Servers must respond only with entities that have been updated since the offset timestamp. The [client ID](#client-ids) of the client that made the most recent change to an entity must be preserved and only updates not made by the requesting client should be returned.
+
+In addition to the central sync endpoint, all entities must be made available for retrieval from domain-specific endpoints. Servers must provide bulk and single-item endpoints for each entity type.
+
+## Pagination {#pagination}
+
+All bulk `GET` requests must be paginated using a stable offset value. For the sync endpoint, this value must be a UTC timestamp in the RFC3339 format. For bulk entity endpoints, the entity ID or another stable incremental value should be used. The server must respond with navigation links in each response to point clients to the next and previous pages of results.
+
+## Modifications {#modifications}
+
+For each entity, servers must keep track of the `last_updated` timestamp and associated requesting client ID when a change is made to the entity metadata. When a client requests a sync response, any entity that has been modified by a different client must be returned.
+
+## Response codes {#response-codes}
+
+The Open Podcast API uses the following HTTP response codes:
+
+* `200`: operation completed successfully.
+* `400`: the client sent an invalid request.
+* `401`: the client attempted to request a resource without authentication.
+* `403`: the client requested access to a resource it does not have permission to interact with.
+* `404`: the client attempted to access a non-existent resource.
+* `500`: the server encountered an error while performing a request.
+
+## Core and optional functionality {#core-and-optional-functionality}
+
+To ensure that the end-user experience is consistent across implementations, the specifications mark endpoints and features as Core (required) and Optional.
+
+* Core: The feature or endpoint MUST be supported by all clients and servers.
+* Optional: The feature or endpoint is considered to be additional functionality.
+
+Clients and servers may optionally support any combination of these features. Any project implementing Optional functionality should inform users about what is supported.
diff --git a/src/content/docs/specs/index.md b/src/content/docs/specs/index.md
new file mode 100644
index 00000000..34bcea73
--- /dev/null
+++ b/src/content/docs/specs/index.md
@@ -0,0 +1,17 @@
+---
+title: API reference
+description: The Open Podcast API is a standard that facilitates the synchronization of podcast data between podcast clients.
+---
+
+:::caution[Important]
+All specifications are currently "in progress". Breaking changes can occur as we implement specifications and address issues.
+:::
+
+This is the API reference for the Open Podcast API. See [conventions](/specs/conventions) for an overview of conventions used for all endpoints. See [authentication](/specs/authentication) for the authentication flow used to authenticate users.
+
+Below you can find the specifications which are already available. We encourage all interested projects offering podcast listening and/or synchronization functionality to adopt and implement defined specifications. We also welcome feedback on these specifications.
+
+## Core endpoints {#core-endpoints}
+
+* [Synchronization endpoint](/specs/sync)
+* [Subscriptions endpoint](/specs/subscriptions)
diff --git a/src/content/docs/specs/index.mdx b/src/content/docs/specs/index.mdx
deleted file mode 100644
index e8fcd1e9..00000000
--- a/src/content/docs/specs/index.mdx
+++ /dev/null
@@ -1,39 +0,0 @@
----
-title: API Specs
-description: All supported API specifications
-next: false
-prev: false
-tableOfContents: false
-sidebar:
- order: 1
----
-
-import { CardGrid, LinkCard } from '@astrojs/starlight/components';
-
-:::caution[Important]
-All specifications are currently 'in progress'. Breaking changes can occur as we implement specifications and address issues.
-:::
-
-Below you can find the specifications which are already available. We encourage all interested projects offering podcast listening and/or synchronization functionality to adopt and implement defined specifications. We also welcome feedback on these sepcifications.
-
-## Core and optional functionality
-
-To ensure that the end-user experience is consistent across implementations, the specifications mark endpoints and features as **Core** (required) and **Optional**.
-
-**Core**
-: The feature or endpoint MUST be supported by all clients and servers.
-
-**Optional**
-: The feature or endpoint is considered to be additional functionality. Clients and servers MAY optionally support any combination of these features. Any project implementing **Optional** functionality SHOULD inform users about what is supported.
-
-Which features a server supports MUST be exposed through a Capabilities endpoint.
-
-## Core endpoints
-
-
-
-
diff --git a/src/content/docs/specs/subscriptions.md b/src/content/docs/specs/subscriptions.md
new file mode 100644
index 00000000..29cd1da1
--- /dev/null
+++ b/src/content/docs/specs/subscriptions.md
@@ -0,0 +1,370 @@
+---
+title: Subscription API specification
+descriptions: Use the subscriptions endpoint to manage podcast subscriptions
+banner:
+ content: This is a core endpoint. All implementing servers and clients must support it.
+---
+
+Subscriptions represent the link between a user an a podcast feed. Users may create new subscriptions and update them to reflect changes to their subscription status.
+
+A subscription entity belongs to a single user entity. When a user subscribes to a feed, it creates a new subscription entity containing all information about the target feed.
+
+## Data model {#data-model}
+
+A subscription entity contains the following properties.
+
+| Property | Type | Required | Description |
+|-------------------|---------------|----------|--------------------------------------------------------------------------|
+| `sync_id` | String (UUID) | Yes | A server-generated ID used to query the entity |
+| `guid` | String | Yes | The podcast `guid` extracted from the feed |
+| `feed_url` | String | Yes | The canonical URL of the RSS feed |
+| `subscribed_at` | Timestamp | Yes | The UTC timestamp at which the user most recently subscribed to the feed |
+| `unsubscribed_at` | Timestamp | No | The UTC timestamp at which the user unsubscribed from the feed |
+
+## Sync actions {#sync-actions}
+
+Clients may perform the following sync actions on subscription entities.
+
+### Subscribe {#subscribe}
+
+Subscribing to a feed creates a new feed entity or clears the `unsubscribed_at` field of an existing feed entity. Subscribed feeds are accessible via the server-generated `sync_id`.
+
+To subscribe or resubscribe to a feed, the client must submit an array of action payloads to the [sync endpoint](/specs/sync) containing the following properties:
+
+| Property | Type | Description |
+|-----------------|---------------------|--------------------------------------------------------------|
+| `type` | String | Must be `subscription` |
+| `action` | String | Must be `subscribe` |
+| `action_id` | String (UUID) | A client-generated UUIDv4 that identifies the action |
+| `occurred_at` | Timestamp (RFC3339) | The UTC timestamp at which the action occurred on the client |
+| `data` | Object | The data payload containing subscription details |
+| `data.guid` | String | The podcast `guid` |
+| `data.feed_url` | String | The canonical URL of the feed |
+
+```sh title="Example"
+curl '/api/v1/sync' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "requests": [
+ {
+ "type": "subscription",
+ "action": "subscribe",
+ "action_id": "a651fd88-555d-4ac4-bdbb-e35846aa4da9",
+ "occurred_at": "2026-07-11T15:30:00.1Z",
+ "data": {
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1"
+ }
+ },
+ {
+ "type": "susbcription",
+ "action": "subscribe",
+ "action_id": "65a57789-3578-4842-89e5-a221f177efb9",
+ "occurred_at": "2026-07-11T13:25:00.1Z",
+ "data": {
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2"
+ }
+ }
+ ]
+}'
+```
+
+
+#### Response {#subscribe-response}
+
+When the server receives a payload containing `"subscribe"` actions, it must follow these rules:
+
+1. If a subscription entity with a matching `feed_url` or `guid` exists and has no `unsubscribed_at` timestamp:
+ 1. Return the existing entity with a `status` of `ignored`.
+1. If a subscription entity with a matching `feed_url` or `guid` exists and has an `unsubscribed_at` timestamp that is earlier than the `occurred_at` timestamp of the payload:
+ 1. Update the existing entity to set its `unsubscribed_at` timestamp to `null`.
+ 1. Update the existing entity to set its `subscribed_at` timestamp to the `occurred_at` timestamp sent in the request.
+ 1. Return the updated entity with a `status` of `applied`.
+1. If a subscription entity with a matching `feed_url` or `guid` exists and has an `unsubscribed_at` timestamp that is later than the `occurred_at` timestamp of the payload:
+ 1. Return the existing entity with a `status` of `ignored`.
+1. If there is no existing subscription entity, the server must create the entity and:
+ 1. Create a unique UUIDv4 `sync_id` for the entity.
+ 1. Set the `subscribed_at` timestamp to the `occurred_at` timestamp in the request body.
+ 1. Return the newly created entity with a `status` of `applied`.
+
+| Property | Type | Description |
+|----------------|---------------|-----------------------------------------------------------------------------------------|
+| `results` | Array | All affected entities in their latest state |
+| `[].action` | String | The action in the request |
+| `[].action_id` | String (UUID) | The client-generated action ID |
+| `[].type` | String | The target entity type |
+| `[].status` | String (enum) | The [status](/specs/sync#action-statuses) of the action |
+| `[].reason` | String | The reason an update was rejected by the server. Only applies if `status` is `rejected` |
+| `[].data` | Object | The affected [subscription entity](#data-model) |
+
+```json title="Response"
+{
+ "results": [
+ {
+ "action_id": "a651fd88-555d-4ac4-bdbb-e35846aa4da",
+ "type": "subscription",
+ "action": "subscribe",
+ "status": "applied",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d76",
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1",
+ "subscribed_at": "2026-07-11T15:30:00.1Z"
+ }
+ },
+ {
+ "action_id": "65a57789-3578-4842-89e5-a221f177efb9",
+ "type": "subscription",
+ "action": "subscribe",
+ "status": "ignored",
+ "data": {
+ "sync_id": "aa840671-fc3b-430b-b8f2-92e6e9c7832b",
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2",
+ "subscribed_at": "2026-03-15T12:06:04.1Z"
+ }
+ }
+ ]
+}
+```
+
+### Unsubscribe {#unsubscribe}
+
+Users may unsubscribe from feeds to stop them appearing in sync results by default. Unsubscribing is a non-destructive action. Servers must update the `unsubscribed_at` timestamp of the subscription entity, but must not delete the entity.
+
+To unsubscribe from a feed, the client must submit an array of action payloads to the [sync endpoint](/specs/sync) containing the following properties:
+
+| Property | Type | Description |
+|----------------|---------------------|--------------------------------------------------------------|
+| `type` | String | Must be `subscription` |
+| `action` | String | Must be `unsubscribe` |
+| `action_id` | String (UUID) | A client-generated UUIDv4 that identifies the action |
+| `occurred_at` | Timestamp (RFC3339) | The UTC timestamp at which the action occurred on the client |
+| `data` | Object | The data payload containing subscription details |
+| `data.sync_id` | String (UUID) | The server-generated sync ID for the entity |
+
+```sh title="Example"
+curl '/api/v1/sync' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer your-token-here' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "requests": [
+ {
+ "type": "subscription",
+ "action": "unsubscribe",
+ "action_id": "9c0de111-fcdb-4463-9e2e-400b624e27f6",
+ "occurred_at": "2026-07-12T12:10:00.1Z",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d7"
+ }
+ },
+ ]
+ }'
+```
+
+#### Response {#unsubscribe-response}
+
+When the server receives a payload containing `"unsubscribe"` actions, it must follow these rules:
+
+1. If no entity with a matching `sync_id` is found:
+ 1. Return the action payload with a `status` of `rejected` and a `reason` of `"Entity not found"`.
+1. If a subscription entity with a matching `sync_id` exists and has an `unsubscribed_at` timestamp that is earlier than the `occurred_at` timestamp of the payload:
+ 1. Return the entity with a `status` of `ignored`.
+1. If a subscription entity with a matching `sync_id` exists and has no `unsubscribed_at` timestamp:
+ 1. Set the `unsubscribed_at` timestamp on the entity to the `occurred_at` timestamp submitted with the action.
+ 1. Return the full updated entity.
+
+| Property | Type | Description |
+|----------------|---------------|-----------------------------------------------------------------------------------------|
+| `results` | Array | All affected entities in their latest state |
+| `[].action` | String | The action in the request |
+| `[].action_id` | String (UUID) | The client-generated action ID |
+| `[].type` | String | The target entity type |
+| `[].status` | String (enum) | The [status](/specs/sync#action-statuses) of the action |
+| `[].reason` | String | The reason an update was rejected by the server. Only applies if `status` is `rejected` |
+| `[].entity` | Object | The affected [subscription entity](#data-model) |
+
+```json title="Response"
+{
+ "results": [
+ {
+ "type": "subscription",
+ "action": "unsubscribe",
+ "action_id": "9c0de111-fcdb-4463-9e2e-400b624e27f6",
+ "status": "applied",
+ "entity": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d7",
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1",
+ "subscribed_at": "2026-07-11T15:30:00.1Z",
+ "unsubscribed_at": "2026-07-12T12:10:00.1Z"
+ }
+ }
+ ]
+}
+```
+
+### Update {#update}
+
+Clients may update the subscription entity metadata by submitting an `update` action to the sync endpoint.
+
+To update subscriptions, the client must submit an array of action payloads to the [sync endpoint](/specs/sync) containing the following properties:
+
+| Property | Type | Description |
+|-----------------|---------------------|--------------------------------------------------------------|
+| `type` | String | Must be `subscription` |
+| `action` | String | Must be `update` |
+| `action_id` | String (UUID) | A client-generated UUIDv4 that identifies the action |
+| `occurred_at` | Timestamp (RFC3339) | The UTC timestamp at which the action occurred on the client |
+| `data` | Object | The data payload containing subscription details |
+| `data.sync_id` | String (UUID) | The server-generated sync ID for the entity |
+| `data.guid` | String | The podcast feed `guid` value |
+| `data.feed_url` | String | The canonical URL of the subscription feed |
+
+```sh title="Example"
+curl '/api/v1/sync' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "requests": [
+ {
+ "type": "subscription",
+ "action": "update",
+ "action_id": "aec337fe-7a9f-4d02-90ad-19d1f552c972",
+ "occurred_at": "2026-07-13T14:09:42.0Z",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d7",
+ "guid": "21dad7d6-94d7-48bc-8052-aa3384624f78",
+ "feed_url": "https://example.com/feed3"
+ }
+ },
+ ]
+ }'
+```
+
+#### Response {#update-response}
+
+When the server receives a payload containing `update` actions, it must follow these rules:
+
+1. If no entity with a matching `sync_id` is found:
+ 1. Return the action payload with a `status` of `rejected` and a `reason` of `"Entity not found"`.
+1. If a subscription with a matching `sync_id` exists and the last modified timestamp of the entity is later than the `occurred_at` timestamp of the payload.
+ 1. Return the entity with a `status` of `ignored`
+1. If a subscription with a matching `sync_id` exists and the last modified timestamp of the entity is earlier than the `occurred_at` timestamp of the payload:
+ 1. Set the `guid` and `feed_url` fields to the values in the payload.
+ 1. Update the last modified timestamp and last modified by identifier.
+ 1. Return the updated entity payload.
+
+```json title="Response"
+{
+ "results": [
+ {
+ "type": "subscription",
+ "action": "update",
+ "action_id": "aec337fe-7a9f-4d02-90ad-19d1f552c972",
+ "status": "applied",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d7",
+ "guid": "21dad7d6-94d7-48bc-8052-aa3384624f7",
+ "feed_url": "https://example.com/feed3",
+ "subscribed_at": "2026-07-11T15:30:00.1Z",
+ "unsubscribed_at": "2026-07-12T12:10:00.1Z"
+ }
+ }
+ ]
+}
+```
+
+## Get all subscriptions {#get-all}
+
+Clients may request a [paginated](/specs/conventions#pagination) list of subscription entities from the `/api/v1/subscriptions` endpoint. The server must return only subscriptions belonging to the requesting user.
+
+```txt title="Endpoint"
+GET /api/v1/subscriptions
+```
+
+The server must return only subscriptions with no `unsubscribed_at` timestamp by default.
+
+### Query parameters {#get-all-query-params}
+
+The client may use the following query parameters when querying the subscriptions endpoint:
+
+| Parameter | Type | In | Description |
+|------------------------|---------|-------|------------------------------------------------------------|
+| `include_unsubscribed` | Boolean | Query | Whether to include unsubscribed feeds. Defaults to `false` |
+| `since` | String | Query | The pagination cursor position to query from |
+| `limit` | Number | Query | The number of results to return per page. Defaults to `30` |
+
+### Example {#get-all-example}
+
+```sh title="Example request" /(limit=.*?)\&/ /(include_unsubscribed=.*?)\'/
+curl '/api/v1/subscriptions?limit=30&include_unsubscribed=true' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+```
+
+```json title="Example response"
+{
+ "results": [
+ {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d76",
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1",
+ "subscribed_at": "2026-07-11T15:30:00.1Z"
+ },
+ {
+ "sync_id": "aa840671-fc3b-430b-b8f2-92e6e9c7832b",
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2",
+ "subscribed_at": "2026-03-15T12:06:04.1Z",
+ "unsubscribed_at": "2026-07-12T12:10:00.1Z"
+ }
+ ],
+ "has_next": false
+}
+```
+## Get a single subscription {#get-one}
+
+Clients may request a single subscription's details by querying its `sync_id`. The server must respond with the full subscription entity whether the `unsubscribed_at` timestamp is populated or not.
+
+```txt title="Endpoint" "{sync_id}"
+GET /api/v1/subscriptions/{sync_id}
+```
+
+The server must respond with a `404 Not found` if no matching subscription is found for the requesting user.
+
+### Query parameters {#get-ony-query-params}
+
+The following query parameters are required in all requests.
+
+| Parameter | Type | In | Description |
+|-----------|---------------|------|------------------------------------------------------------|
+| `sync_id` | String (UUID) | Path | The server-generated `sync_id` for the target subscription |
+
+### Example {#get-one-example}
+
+```sh title="Example" "aa840671-fc3b-430b-b8f2-92e6e9c7832b"
+curl '/api/v1/subscriptions/aa840671-fc3b-430b-b8f2-92e6e9c7832b \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+```
+
+```json title="Example response"
+{
+ "sync_id": "aa840671-fc3b-430b-b8f2-92e6e9c7832b",
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2",
+ "subscribed_at": "2026-03-15T12:06:04.1Z",
+ "unsubscribed_at": "2026-07-12T12:10:00.1Z"
+}
+```
diff --git a/src/content/docs/specs/subscriptions/add-new.mdx b/src/content/docs/specs/subscriptions/add-new.mdx
deleted file mode 100644
index a94e28b8..00000000
--- a/src/content/docs/specs/subscriptions/add-new.mdx
+++ /dev/null
@@ -1,244 +0,0 @@
----
-title: Add a new subscription
-description: Add a new subscription
-sidebar:
- order: 2
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-POST /v1/subscriptions
-```
-
-This endpoint enables clients to add new subscriptions to the system for the authenticated user. It returns an array of `success` responses for newly added subscriptions, and an array of `failure` responses for subscriptions that couldn't be added.
-
-| Field | Type | Required? | Description |
-| ---------------------- | -------- | --------- | -------------------------------------------------------------------------------------------- |
-| `feed_url` | String | Yes | The URL of the podcast RSS feed |
-| `guid` | String | Yes | The globally unique ID of the podcast |
-| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast |
-| `subscription_changed` | Datetime | Yes | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] |
-
-## Request parameters
-
-The client MUST provide a list of objects containing the following parameters:
-
-| Field | Type | Required? | Description |
-| ---------- | ------ | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
-| `feed_url` | String | Yes | The URL of the podcast RSS feed. The client must provide a protocol (for example: `http` or `https`) and preserve any parameters |
-| `guid` | String | No | The GUID found in the podcast RSS feed |
-
-:::caution[Important]
-If a client passes a `guid` this MUST be treated as authoritative by the server. The client MAY pass a `guid` **only** if it is parsed from the podcast RSS feed.
-:::
-
-
-
-
- ```json
- {
- "subscriptions": [
- {
- "feed_url": "https://example.com/rss1"
- },
- {
- "feed_url": "https://example.com/rss2"
- },
- {
- "feed_url": "https://example.com/rss3"
- },
- {
- "feed_url": "https://example.com/rss4",
- "guid": "2d8bb39b-8d34-48d4-b223-a0d01eb27d71"
- }
- ]
- }
- ```
-
-
-
-
- ```xml
-
-
-
- https://example.com/feed1
-
-
- https://example.com/feed2
-
-
- https://example.com/feed3
-
-
- https://example.com/feed4
- 2d8bb39b-8d34-48d4-b223-a0d01eb27d71
-
-
- ```
-
-
-
-
-## Server-side behavior
-
-When new feeds are posted to the server, the server MUST return a success response to the client immediately to acknowledge the request. To ensure that data can be returned immediately, the following flow MUST be followed:
-
-1. The client sends a payload to the server
-2. For each object in the payload, the server does the following:
- 1. Checks if there's a `guid` entry in the payload
- - If a `guid` is present, the server stores the `guid` for later use
- - If no `guid` is present, the server generates a `guid` for later use
- 2. Checks to see if there is an existing entry with the same `guid` or `feed_url`
- - If an existing entry is found, the server sets the `is_subscribed` field to `true` and updates the `subscription_changed` date to the current date. If the `deleted` field is populated, the field is set to `NULL` to show that the subscription is active
- - If no existing entry is found, the server creates a new subscription entry
-3. The server returns a success payload containing the subscription information for each object in the request payload.
-
-
-
-### Subscription GUID update
-
-If the client doesn't send a `guid` in the subscription payload, the server MUST create one immediately to ensure the following:
-
-1. Each entry has an associated `guid`
-2. The client receives a success response as quickly as possible
-
-Once this is done, the server SHOULD asynchronously verify that there isn't a more authoritative GUID available. The following flow should be used:
-
-1. The server fetches and parses the RSS feed to search for a [`guid` field in the `podcast` namespace](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid).
-2. If a more authoritative `guid` is found, the server must update the subscription entry as follows:
- 1. Create a new subscription entry with the new `guid`
- 2. Update the `new_guid` field in the existing entry to point to the new `guid`
- 3. Update the `guid_changed` field in the existing entry to the current date
-
-
-
-## Example request
-
-
-
-
- ```console
- $ curl --location '/subscriptions' \
- --header 'Content-Type: application/json' \
- --data '{
- "subscriptions": [
- {
- "feed_url": "https://example.com/feed1"
- },
- {
- "feed_url": "https://example.com/feed2"
- },
- {
- "feed_url": "https://example.com/feed3"
- },
- {
- "feed_url": "example.com/feed4",
- "guid": "2d8bb39b-8d34-48d4-b223-a0d01eb27d71"
- }
- ]
- }'
- ```
-
-
-
-
- ```console
- $ curl --location '/subscriptions' \
- --header 'Content-Type: application/xml' \
- --data '
-
-
- https://example.com/feed1
-
-
- https://example.com/feed2
-
-
- https://example.com/feed3
-
-
- example.com/feed4
- 2d8bb39b-8d34-48d4-b223-a0d01eb27d71
-
- '
- ```
-
-
-
-
-## Example 200 response
-
-
-
-
- ```json
- {
- "success": [
- {
- "feed_url": "https://example.com/rss1",
- "guid": "8d1f8f09-4f50-4327-9a63-639bfb1cbd98",
- "is_subscribed": true,
- "subscription_changed": "2023-02-23T14:00:00.000Z"
- },
- {
- "feed_url": "https://example.com/rss2",
- "guid": "968cb508-803c-493c-8ff2-9e397dadb83c",
- "is_subscribed": true,
- "subscription_changed": "2023-02-23T14:00:00.000Z"
- },
- {
- "feed_url": "https://example.com/rss3",
- "guid": "e672c1f4-230d-4ab4-99d3-390a9f835ec1",
- "is_subscribed": true,
- "subscription_changed": "2023-02-23T14:00:00.000Z"
- }
- ],
- "failure": [
- {
- "feed_url": "example.com/rss4",
- "message": "No protocol present"
- }
- ]
- }
- ```
-
-
-
-
- ```xml
-
-
-
- https://example.com/rss1
- 8d1f8f09-4f50-4327-9a63-639bfb1cbd98
- true
- 2023-02-23T14:00:00.000Z
-
-
- https://example.com/rss2
- 968cb508-803c-493c-8ff2-9e397dadb83c
- true
- 2023-02-23T14:00:00.000Z
-
-
- https://example.com/rss3
- e672c1f4-230d-4ab4-99d3-390a9f835ec1
- true
- 2023-02-23T14:00:00.000Z
-
-
- example.com/rss4
- No protocol present
-
-
- ```
-
-
-
-
-[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/subscriptions/delete.mdx b/src/content/docs/specs/subscriptions/delete.mdx
deleted file mode 100644
index c9008739..00000000
--- a/src/content/docs/specs/subscriptions/delete.mdx
+++ /dev/null
@@ -1,83 +0,0 @@
----
-title: Delete a subscription
-description: Fetch the status of a deletion process
-sidebar:
- order: 6
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-DELETE /v1/subscriptions/{guid}
-```
-
-This endpoint allows clients to mark a feed as deleted. This prevents the server from updating the feed in the background and prevents the server from returning any information, such as playback positions, related to the given associated feed.
-
-## Server-side behavior
-
-:::caution[Important]
-The server MUST enact all cascade deletions using ACID transactions. If the deletion process fails at any point in the transaction, **all** transactions MUST be rolled back to maintain integrity.
-:::
-
-To ensure that `DELETE` requests are handled asynchronously, the server MUST respond to deletion requests immediately with a `202 (Accepted)` status containing a `deletion_id`. This ID MUST correspond to a status object on the server containing details of the deletion process. The client MUST be able to [query the status of a deletion](/specs/subscriptions/status) to check its progress.
-
-| Parameter | Type | Required? | Description |
-| ---------- | ------- | --------- | --------------------------------------------------------------------------------------------------- |
-| `id` | Integer | Yes | The ID of the deletion object |
-| `success` | Boolean | Yes | Whether or not the deletion was completed successfully |
-| `complete` | Boolean | Yes | Whether or not the deletion process has finished |
-| `message` | String | No | A status message indicating the current status of the deletion, or any errors that were encountered |
-
-The following flow MUST be followed:
-
-1. The client sends a `DELETE` request for a subscription object
-2. The server creates a new deletion status object and returns the `deletion_id` in a `202 (Accepted)` response
-3. The server attempts to perform a cascade delete on all related items
- 1. If a failure occurs at any point in the process, all transactions are rolled back and the status object is updated to show the following:
- - `complete`: Must be true
- - `success`: Must be `false`
- - `message`: Should be updated to contain a meaningful error message
- 2. If all deletions are successful, the status object is updated to show the following:
- - `complete`: Must be true
- - `success`: Must be `true`
- - `message`: Should be updated to contain a success message
-
-If the client attempts to [fetch a deleted subscription](/specs/subscriptions/get-all), the server MUST respond with a `410 (Gone)` status code to indicate the object and its associated data have been deleted.
-
-
-
-## Example request
-
-```console
-$ curl --location --request DELETE \
-'/v1/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71'
-```
-
-## Example 202 response
-
-
-
-
- ```json
- {
- "deletion_id": 25,
- "message": "Deletion request was received and will be processed"
- }
- ```
-
-
-
-
- ```xml
-
-
- 25
- Deletion request was received and will be processed
-
- ```
-
-
-
diff --git a/src/content/docs/specs/subscriptions/get-all.mdx b/src/content/docs/specs/subscriptions/get-all.mdx
deleted file mode 100644
index 3a80a2ba..00000000
--- a/src/content/docs/specs/subscriptions/get-all.mdx
+++ /dev/null
@@ -1,295 +0,0 @@
----
-title: Get all subscriptions
-description: Get all subscriptions for a user
-sidebar:
- order: 3
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-GET /v1/subscriptions
-```
-
-This endpoint enables clients to return all subscription information relating to the authenticated user. It returns pagination information and an array of `subscriptions`.
-
-## Response fields
-
-### Metadata
-
-| Field | Type | Required? | Description |
-| ---------- | ------ | --------- | ------------------------------------------------ |
-| `total` | Number | Yes | The total number of objects returned by the call |
-| `page` | Number | Yes | The number of the page returned in the call |
-| `per_page` | Number | Yes | The number of results returned per page |
-| `next` | String | No | The URL for the next page of results |
-| `previous` | String | No | The URL for the previous page of results |
-
-### Subscription fields
-
-| Field | Type | Required? | Description |
-| ---------------------- | -------------- | --------- | ----------------------------------------------------------------------------------------------------- |
-| `feed_url` | String | Yes | The URL of the podcast RSS feed |
-| `guid` | String\ | Yes | The globally unique ID of the podcast |
-| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast |
-| `subscription_changed` | Datetime | No | The date on which details relating to the subscription last changed. Presented in [ISO 8601 format] |
-| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] |
-| `new_guid` | String\ | No | The new GUID associated with the podcast |
-| `deleted` | Datetime | No | The date on which the subscription was deleted. Only returned if the field is not `NULL` |
-
-## Parameters
-
-The client MAY add the following parameters to their call:
-
-| Field | Type | In | Required? | Description |
-| ---------- | -------- | ----- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `since` | DateTime | Query | No | The date from which the server should return objects. The server only returns entries whose `subscription_changed`, `guid_changed`, or `deleted` fields are greater than this parameter. Expected in [ISO 8601 format] |
-| `page` | Number | Query | No | The page of results to be returned by the server. Defaults to `1` if not present |
-| `per_page` | Number | Query | No | The number of results to return in each call. Defaults to `50` if not present |
-
-:::note
-If no `since` parameter is provided, the server MUST return all current subscription information.
-:::
-
-## Server-side behavior
-
-If the entry contains a `new_guid`, the server MUST return the newest `guid` associated with the entry in the response's `new_guid` field. For example: if a subscription has received 2 new `guid`s, the server MUST return:
-
-- The subscription's `guid` as it was at the date passed in the `since` parameter, or the original entry's `guid` if no `since` parameter is passed
-- The subscription's latest `guid` in the `new_guid` field
-
-This ensures the client has the most up-to-date entry for the subscription.
-
-
-
-## Client behavior
-
-The client SHOULD update its local subscription data to match the information returned in the response. On receipt of a deleted subscription, the client SHOULD present the user with the option to remove their local data or [send their local data to the server](/specs/subscriptions/add-new) to reinstate the subscription details.
-
-### Resolution example
-
-This example demonstrates how the server resolves a `new_guid` field for a subscription that has received three GUIDs. Here is how the data is represented in the database:
-
-| `feed_url` | `guid` | `is_subscribed` | `subscription_changed` | `guid_change` | `new_guid` |
-| -------------------------- | -------------------------------------- | --------------- | -------------------------- | -------------------------- | -------------------------------------- |
-| `https://example.com/rss1` | `64c1593b-5a1e-4e89-b8a3-d91501065e80` | `true` | `2022-03-21T18:45:35.513Z` | `2022-03-21T19:00:00.000Z` | `daac3ce5-7b16-4cf0-8294-86ad71944a64` |
-| `https://example.com/rss1` | `daac3ce5-7b16-4cf0-8294-86ad71944a64` | `true` | `2022-03-21T18:45:35.513Z` | `2022-12-23T10:24:14.670Z` | `36a47c4c-4aa3-428a-8132-3712a8422002` |
-| `https://example.com/rss1` | `36a47c4c-4aa3-428a-8132-3712a8422002` | `true` | `2022-03-21T18:45:35.513Z` | `2022-12-23T10:24:14.670Z` | |
-
-#### Scenario 1
-
-In this scenario, the client requests all subscriptions and **doesn't** pass a `since` parameter. This means the server passes the **original** GUID in the `guid` field, and the **latest** GUID in the `new_guid`field.
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions?page=1&per_page=5' \
- -H 'accept: application/json'
- ```
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions?page=1&per_page=5' \
- -H 'accept: application/xml'
- ```
-
-
-
-
-
-
-
- ```json {8, 11} collapse={2-4}
- {
- "total": 1,
- "page": 1,
- "per_page": 5,
- "subscriptions": [
- {
- "feed_url": "https://example.com/rss1",
- "guid": "64c1593b-5a1e-4e89-b8a3-d91501065e80",
- "is_subscribed": true,
- "guid_changed": "2022-12-23T10:24:14.670Z",
- "new_guid": "36a47c4c-4aa3-428a-8132-3712a8422002"
- }
- ]
- }
- ```
-
-
-
-
- ```xml {8, 11} collapse={3-5}
-
-
- 1
- 1
- 5
-
- https://example.com/rss1
- 64c1593b-5a1e-4e89-b8a3-d91501065e80
- true
- 2022-12-23T10:24:14.670Z
- 36a47c4c-4aa3-428a-8132-3712a8422002
-
-
- ```
-
-
-
-
-#### Scenario 2
-
-In this scenario, the client requests all subscriptions and specifies a `since` date of `2022-05-30T00:00:00.000Z`. Since the first GUID change occurred before this date, and the second GUID change occurred after this date, the server responds with the **second** GUID in the `guid` field, and the **latest** GUID in the `new_guid` field.
-
-
-
-
- ```console "since=2022-05-30T00%3A00%3A00.000Z"
- $ curl -X 'GET' \
- '/v1/subscriptions?since=2022-05-30T00%3A00%3A00.000Z&page=1&per_page=5' \
- -H 'accept: application/json'
- ```
-
-
-
-
- ```console "since=2022-05-30T00%3A00%3A00.000Z"
- $ curl -X 'GET' \
- '/v1/subscriptions?since=2022-05-30T00%3A00%3A00.000Z&page=1&per_page=5' \
- -H 'accept: application/xml'
- ```
-
-
-
-
-
-
-
- ```json {8, 11} collapse={2-4}
- {
- "total": 1,
- "page": 1,
- "per_page": 5,
- "subscriptions": [
- {
- "feed_url": "https://example.com/rss1",
- "guid": "daac3ce5-7b16-4cf0-8294-86ad71944a64",
- "is_subscribed": true,
- "guid_changed": "2022-12-23T10:24:14.670Z",
- "new_guid": "36a47c4c-4aa3-428a-8132-3712a8422002"
- }
- ]
- }
- ```
-
-
-
-
- ```xml {8, 11} collapse={3-5}
-
-
- 1
- 1
- 5
-
- https://example.com/rss1
- daac3ce5-7b16-4cf0-8294-86ad71944a64
- true
- 2022-12-23T10:24:14.670Z
- 36a47c4c-4aa3-428a-8132-3712a8422002
-
-
- ```
-
-
-
-
-## Example request
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions?since=2022-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \
- -H 'accept: application/json'
- ```
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions?since=2022-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \
- -H 'accept: application/xml'
- ```
-
-
-
-
-## Example 200 response
-
-
-
-
- ```json
- {
- "total": 2,
- "page": 1,
- "per_page": 5,
- "subscriptions": [
- {
- "feed_url": "https://example.com/rss1",
- "guid": "31740ac6-e39d-49cd-9179-634bcecf4143",
- "is_subscribed": true,
- "guid_changed": "2022-09-21T10:25:32.411Z",
- "new_guid": "8d1f8f09-4f50-4327-9a63-639bfb1cbd98"
- },
- {
- "feed_url": "https://example.com/rss2",
- "guid": "968cb508-803c-493c-8ff2-9e397dadb83c",
- "is_subscribed": false,
- "subscription_changed": "2022-04-24T17:53:21.573Z"
- }
- ]
- }
- ```
-
-
-
-
- ```xml
-
-
- 2
- 1
- 5
-
- https://example.com/rss1
- 31740ac6-e39d-49cd-9179-634bcecf4143
- true
- 2022-09-21T10:25:32.411Z
- 8d1f8f09-4f50-4327-9a63-639bfb1cbd98
-
-
- https://example.com/rss2
- 968cb508-803c-493c-8ff2-9e397dadb83c
- false
- 2022-04-24T17:53:21.573Z
-
-
- ```
-
-
-
-
-[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/subscriptions/get-single.mdx b/src/content/docs/specs/subscriptions/get-single.mdx
deleted file mode 100644
index 9a83b2e6..00000000
--- a/src/content/docs/specs/subscriptions/get-single.mdx
+++ /dev/null
@@ -1,127 +0,0 @@
----
-title: Get a single subscription
-description: Get a single subscription for a user
-sidebar:
- order: 4
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-GET /v1/subscriptions/{guid}
-```
-
-This endpoint returns subscription information relating to a specific subscription for the authenticated user. It returns the following information:
-
-| Field | Type | Required? | Description |
-| ---------------------- | -------------- | --------- | ----------------------------------------------------------------------------------------------------- |
-| `feed_url` | String | Yes | The URL of the podcast RSS feed |
-| `guid` | String\ | Yes | The globally unique ID of the podcast |
-| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast or not |
-| `subscription_changed` | Datetime | No | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] |
-| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] |
-| `new_guid` | String\ | No | The new GUID associated with the podcast |
-| `deleted` | Datetime | No | The date on which the subscription was deleted. Only returned if the field is not `NULL` |
-
-## Parameters
-
-The client MUST send the subscription's `guid` in the path of the request.
-
-## Server-side behavior
-
-If the entry contains a `new_guid`, the server MUST return the newest `guid` associated with the entry in the response's `new_guid` field. For example: if a subscription has received 2 new `guid`s, the server MUST return:
-
-- The subscription's `guid` passed in the request path
-- The subscription's latest `guid` in the `new_guid` field
-
-This ensures the client has the most up-to-date entry for the subscription.
-
-
-
-## Client behavior
-
-The client SHOULD update its local subscription data to match the information returned in the response. On receipt of a deleted subscription, the client SHOULD present the user with the option to remove their local data or [send their local data to the server](/specs/subscriptions/add-new) to reinstate the subscription details.
-
-## Example request
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions/968cb508-803c-493c-8ff2-9e397dadb83c' \
- -H 'accept: application/json'
- ```
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/subscriptions/968cb508-803c-493c-8ff2-9e397dadb83c' \
- -H 'accept: application/xml'
- ```
-
-
-
-
-## Example 200 response
-
-
-
-
- ```json
- {
- "feed_url": "https://example.com/feed2",
- "guid": "968cb508-803c-493c-8ff2-9e397dadb83c",
- "is_subscribed": true
- }
- ```
-
-
-
-
- ```xml
-
-
- https://example.com/feed2
- 968cb508-803c-493c-8ff2-9e397dadb83c
- true
-
- ```
-
-
-
-
-## Example 410 response
-
-If a subscription has been [deleted](/specs/subscriptions/delete), the server must respond with a `410 (Gone)` response to inform the client.
-
-
-
-
- ```json
- {
- "code": 410,
- "message": "Subscription has been deleted"
- }
- ```
-
-
-
-
- ```xml
-
-
- 410
- Subscription has been deleted
-
- ```
-
-
-
-
-[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/subscriptions/index.mdx b/src/content/docs/specs/subscriptions/index.mdx
deleted file mode 100644
index a6975902..00000000
--- a/src/content/docs/specs/subscriptions/index.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: Subscriptions endpoint
-description: An endpoint for syncing subscriptions between devices.
-prev: false
-sidebar:
- label: Overview
- order: 1
----
-
-import CoreEndpoint from "@partials/_core-endpoint.mdx";
-
-
-
-The subscriptions endpoint is used to synchronize subscriptions between a server and connected clients. The server is treated as the authoritative source for subscription information. Clients can query the endpoint by specifying the datetime from which they want to fetch changes to ensure they only fetch information that is relevant to them since their last sync.
-
-Subscriptions represent the feeds a user has subscribed to. A subscription object stores essential information about each subscription and acts as an index that links other activity information together.
-
-## Important data fields
-
-| Field | Type | Nullable? | Description |
-| ---------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `feed_url` | String | No | The URL of the podcast RSS feed |
-| `guid` | String\ | No | The globally unique ID of the podcast |
-| `is_subscribed` | Boolean | No | Whether the user is subscribed to the podcast |
-| `subscription_changed` | Datetime | No | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] |
-| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] |
-| `new_guid` | String\ | Yes | The new GUID associated with the podcast |
-| `deleted` | Datetime | Yes | The date on which data associated with the subscription was deleted by the user. This field is used to determine whether a `410 (Gone)` response should be returned |
-
-:::note[Tombstoning]
-Servers SHOULD hold all previous `guid` and `feed_url` field data with a link to the succeeding data (such that a path of values can be followed) or with a link to the most recent data. This enables the server to handle situations in which clients submit old data. For example:
-
-- A user finds a podcast, whose URL had changed, and adds the old URL in the app. Because the client doesn't have the old URL in its database, it recognizes the podcast as **new** and POSTs the `feed_url` to the `/subscriptions` endpoint. If the user is already subscribed to the podcast (with the current feed URL) this would lead to a duplicate subscription.
-- A user has a device that they didn't use for a very long time. In that time, a podcaster added a GUID in their feed, leading to updated data in this field. When the client connects to the server again to pull all episode changes since the last connection, it retrieves episodes with their current subscription `guid`. The client won't recognize the subscription and fail to update the status of episodes.
- :::
-
-[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/subscriptions/status.mdx b/src/content/docs/specs/subscriptions/status.mdx
deleted file mode 100644
index d5705f51..00000000
--- a/src/content/docs/specs/subscriptions/status.mdx
+++ /dev/null
@@ -1,138 +0,0 @@
----
-title: Deletion status endpoint
-description: Fetch the status of a deletion process
-sidebar:
- order: 7
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-GET /v1/deletions/{id}
-```
-
-This endpoint enables clients to query the status of a [deletion](/specs/subscriptions/delete). When a client sends a `DELETE` request, the server MUST respond with a `deletion_id` that can be used with this endpoint to check whether a deletion has been successfully actioned.
-
-| Parameter | Type | Required? | Description |
-| ------------- | ------- | --------- | --------------------------------------------------------------------------------------------------- | --------- | --------- |
-| `deletion_id` | Integer | Yes | The ID of the deletion object |
-| `status` | String | Yes | A status message indicating the status of the deletion. Available values: `SUCCESS` | `FAILURE` | `PENDING` |
-| `message` | String | No | A status message indicating the current status of the deletion, or any errors that were encountered |
-
-## Parameters
-
-The client MUST send the deletion's `id` in the path of the request.
-
-## Example request
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/deletions/25' \
- -H 'accept: application/json'
- ```
-
-
-
-
- ```console
- $ curl -X 'GET' \
- '/v1/deletions/25' \
- -H 'accept: application/xml'
- ```
-
-
-
-
-## Example 200 response
-
-The server MUST send a `200 (Success)` if it can fetch a status object without issue. This response MUST contain information about the `deletion_id` passed in the query path.
-
-### Successful deletion
-
-
-
-
- ```json {3}
- {
- "deletion_id": 25,
- "status": "SUCCESS",
- "message": "Subscription deleted successfully"
- }
- ```
-
-
-
-
- ```xml {3}
-
-
- 25
- SUCCESS
- Subscription deleted successfully
-
- ```
-
-
-
-
-### Pending deletion
-
-
-
-
- ```json {3}
- {
- "deletion_id": 25,
- "status": "PENDING",
- "message": "Deletion is pending"
- }
- ```
-
-
-
-
- ```xml {3}
-
-
- 25
- PENDING
- Deletion is pending
-
- ```
-
-
-
-
-### Failed deletion
-
-
-
-
- ```json {3}
- {
- "deletion_id": 25,
- "status": "FAILURE",
- "message": "The deletion process encountered an error and was rolled back"
- }
- ```
-
-
-
-
- ```xml {3}
-
-
- 25
- FAILURE
- The deletion process encountered an error and was rolled back
-
- ```
-
-
-
diff --git a/src/content/docs/specs/subscriptions/update.mdx b/src/content/docs/specs/subscriptions/update.mdx
deleted file mode 100644
index 136f8375..00000000
--- a/src/content/docs/specs/subscriptions/update.mdx
+++ /dev/null
@@ -1,128 +0,0 @@
----
-title: Update a subscription
-description: Update details about a subscription
-sidebar:
- order: 5
----
-
-import CoreAction from "@partials/_core-action.mdx";
-import { Tabs, TabItem } from '@astrojs/starlight/components';
-
-
-
-```http title="Endpoint"
-PATCH /v1/subscriptions/{guid}
-```
-
-This endpoint allows clients to update information about a subscription. The client MAY update the following information:
-
-- The podcast's GUID
-- The podcast's feed URL
-- An update to the subscription status for the user
-
-This endpoint returns the following information:
-
-| Field | Type | Required? | Description |
-| ---------------------- | -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `new_feed_url` | String | No | The URL of the podcast RSS feed. Only returned if the `feed_url` field was updated by the request |
-| `is_subscribed` | Boolean | No | Whether the user is subscribed to the podcast or not. Only returned if the `is_subscribed` field was updated by the request |
-| `subscription_changed` | Datetime | No | The date on which the `is_subscribed`or `feed_url` field was last updated. Presented in [ISO 8601 format]. Only returned if the `is_subscribed` field was updated by the request |
-| `guid_changed` | Datetime | No | The date on which the podcast's GUID was last updated. Presented in [ISO 8601 format]. Only returned if the `guid` field was updated by the request |
-| `new_guid` | String\ | No | The new GUID associated with the podcast. Only returned if the `guid` field was updated by the request |
-
-## Parameters
-
-The client MUST pass the subscription GUID in the query path and add at least one field update in the request body.
-
-| Parameter | Type | In | Required? | Description |
-| --------------- | --------------- | ----- | --------- | ------------------------------------------------------------ |
-| `guid` | String | Query | Yes | The GUID of the subscription object that needs to be updated |
-| `new_feed_url` | String | Body | No | The URL of the new RSS feed for the subscription |
-| `new_guid` | String \ | Body | No | The new GUID of the podcast |
-| `is_subscribed` | Boolean | Body | No | Whether the user is subscribed to the podcast or not |
-
-## Server-side behavior
-
-On receipt of a PATCH request for a subscription, the server MUST do the following:
-
-1. If the subscription in the request has a `new_guid` specified in the database, follow the `new_guid` chain to find the **latest** version of the subscription
-2. If the request contains a `new_feed_url` parameter:
- 1. Update the subscription entry's `feed_url` field to the new value
- 2. Update the subscription entry's `subscription_changed` field to the current date
-3. If the request contains a `new_guid` parameter:
- 1. Check if the GUID is already present in the system
- 2. If the GUID is already present, update the subscription entry's `new_guid` field to point to the existing entry
- 3. If the GUID isn't already present, create a new subscription entry and update the existing entry's `new_guid` field to point to the newly created entry
- 4. Update the subscription entry's `guid_changed` to the current date
-4. If the request contains an `is_subscribed` parameter:
- 1. Update the subscription entry's `is_subscribed` to the new value
- 2. Update the subscription entry's `subscription_changed` field to the current date
-5. Return a summary of the changes
-
-
-
-## Example request
-
-
-
-
- ```console
- $ curl --location --request PATCH '/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71' \
- --header 'Content-Type: application/json' \
- --data '{
- "new_feed_url": "https://example.com/rss5",
- "new_guid": "965fcecf-ce04-482b-b57c-3119b866cc61",
- "is_subscribed": false
- }'
- ```
-
-
-
-
- ```console
- $ curl --location --request PATCH '/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71' \
- --header 'Content-Type: application/xml' \
- --data '
-
- https://example.com/rss5
- 965fcecf-ce04-482b-b57c-3119b866cc61
- false
- '
- ```
-
-
-
-
-## Example 200 response
-
-
-
-
- ```json
- {
- "new_feed_url": "https://example.com/rss5",
- "is_subscribed": false,
- "subscription_changed": "2023-02-23T14:41:00.000Z",
- "guid_changed": "2023-02-23T14:41:00.000Z",
- "new_guid": "965fcecf-ce04-482b-b57c-3119b866cc61"
- }
- ```
-
-
-
-
- ```xml
-
-
- https://example.com/rss5
- false
- 2023-02-23T14:41:00.000Z
- 2023-02-23T14:41:00.000Z
- 965fcecf-ce04-482b-b57c-3119b866cc61
-
- ```
-
-
-
-
-[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/sync.md b/src/content/docs/specs/sync.md
new file mode 100644
index 00000000..0d2d3aef
--- /dev/null
+++ b/src/content/docs/specs/sync.md
@@ -0,0 +1,197 @@
+---
+title: Synchronization endpoint
+description: The Open Podcast API uses a central synchronization endpoint for sharing bulk updates between clients and the server.
+banner:
+ content: This is a core endpoint. All implementing servers and clients must support it.
+---
+
+The synchronization endpoint is the main entry point to the Open Podcast API. It allows clients to submit batches of updates and retrieve a paginated payload of updates made since a provided timestamp.
+
+The synchronization endpoint exposes updates for all supported entity types in a single response payload. Each entity type may also be fetched from its dedicated endpoint.
+
+## Batch processing {#batch-processing}
+
+Clients may submit up to 30 actions in a batch to perform bulk changes once they come online. All actions must be contained in a `requests` array.
+
+```txt title="Endpoint"
+POST /api/v1/sync
+```
+
+### Action format {#action-format}
+
+Each action must contain the following properties:
+
+| Property | Type | Description |
+|---------------|---------------------|--------------------------------------------------------------|
+| `type` | String | The target entity type |
+| `action` | String | The action being undertaken |
+| `action_id` | String (UUID) | A client-generated UUIDv4 identifier for the action |
+| `occurred_at` | Timestamp (RFC3339) | The UTC timestamp at which the action occurred on the client |
+| `data` | Object | The payload associated with the action |
+
+```sh title="Example"
+curl '/api/v1/sync' \
+ -X 'POST' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+ -d '{
+ "requests": [
+ {
+ "type": "subscription",
+ "action": "subscribe",
+ "action_id": "a651fd88-555d-4ac4-bdbb-e35846aa4da9",
+ "occurred_at": "2026-07-11T15:30:00.1Z",
+ "data": {
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1"
+ }
+ },
+ {
+ "type": "susbcription",
+ "action": "subscribe",
+ "action_id": "65a57789-3578-4842-89e5-a221f177efb9",
+ "occurred_at": "2026-07-11T13:25:00.1Z",
+ "data": {
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2"
+ }
+ }
+ ]
+}'
+```
+
+### Response format {#response-format}
+
+The server must respond to bulk updates with a `results` array containing the result of each requested action. Each action must contain the following properties:
+
+| Property | Type | Description |
+|-------------|---------------|-----------------------------------------------------------------------------------------|
+| `type` | String | Must be `subscription` |
+| `action` | String | The action performed in the request object |
+| `action_id` | String (UUID) | The client-generated action ID |
+| `status` | String (enum) | The [status](#action-statuses) of the action |
+| `reason` | String | The reason an update was rejected by the server. Only applies if `status` is `rejected` |
+| `data` | Object | The affected entity data |
+
+```json title="Example"
+{
+ "results": [
+ {
+ "type": "subscription",
+ "action": "unsubscribe",
+ "action_id": "aec337fe-7a9f-4d02-90ad-19d1f552c972",
+ "status": "applied",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d7",
+ "guid": "21dad7d6-94d7-48bc-8052-aa3384624f7",
+ "feed_url": "https://example.com/feed3",
+ "subscribed_at": "2026-07-11T15:30:00.1Z",
+ "unsubscrbed_at": "2026-07-12T12:10:00.1Z"
+ }
+ }
+ ]
+}
+```
+
+### Action statuses {#action-statuses}
+
+The following statuses may be returned for each action:
+
+| Status | Description |
+|------------|--------------------------------------------------------------------------------------|
+| `applied` | The server validated and applied the action |
+| `ignored` | The action was not applied due to the action occurring before the most recent update |
+| `rejected` | The action was not applied due to the request not being valid |
+
+## Sync requests {#sync-requests}
+
+Clients may request a bulk synchronization of data from the synchronization endpoint. The server must respond with a [paginated list](/specs/convention#pagination) of entities belonging to the requesting user.
+
+```txt title="Endpoint"
+GET /api/v1/sync
+```
+
+### Parameters {#sync-parameters}
+
+The client should use the following query parameters to limit the response from the endpoint:
+
+| Parameter | Type | In | Description |
+|---------------|---------------------|-------|-------------------------------------------------------------------------------|
+| `since` | Timestamp (RFC3339) | Query | Limits results to entities updated since the provided UTC timestamp |
+| `limit` | Number | Query | The number of results to include per-page. Defaults to `50` |
+| `include` | Array (String) | Query | A list of entities to include. Defaults to `all` |
+| `include_own` | Boolean | Query | Whether to include updates made by the requesting client. Defaults to `false` |
+
+
+```sh title="Example" /(since=.*?)\&/ /(limit=.*?)\&/ /(include=.*?)\&/ /(include_own=.*?)\'/
+curl '/api/v1/sync?since=2026-07-11T15:30:00.1Z&limit=2&include=subscriptions&include_own=false' \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer your-token-here' \
+ -H 'Client-ID: cca4ad10-dc35-4572-8f11-125dc465428a'
+```
+
+### Response {#sync-response}
+
+The server must respond with a `"results"` array containing a chronological list of all updates made since the `since` timestamp provided, or all updates if no `since` parameter is provided.
+
+#### Pagination {#sync-pagination}
+
+The response must contain [pagination properties](/specs/conventions#pagination) to instruct clients on how to navigate the response.
+
+| Property | Type | Required | Description |
+|------------------|--------------|----------|--------------------------------------------------------------------|
+| `has_more` | Boolean | Yes | Whether there are more pages of results |
+| `next_since` | String (URL) | No | The URL to the next page of results, if more results are available |
+| `previous_since` | String (URL) | No | The URL to the previous page of results, if applicable |
+
+The `next_since` and `previous_since` must provide a full URL to a page of resources. The `since` values must be calculated as follows:
+
+* `next_since`: The `last_updated` timestamp of the latest update in the current response. Clients use this value in their next sync request to fetch any changes that occurred after the latest item they have processed.
+* `previous_since`: The `last_updated` timestamp of the oldest update in the current response. Clients may use this value to retrieve the preceding page of results when paging backwards.
+
+:::note
+Navigation links must provide an updated `since` parameter and must preserve all other parameters present in the original request.
+:::
+
+#### Result properties {#sync-result-properties}
+
+Each response object must include the following properties:
+
+| Property | Type | Description |
+|----------------|---------------------|--------------------------------------------------------|
+| `type` | String | The entity type |
+| `last_updated` | Timestamp (RFC3339) | The UTC timestamp at which the entity was last updated |
+| `data` | Object | The canonical representation of the entity |
+
+#### Example {#sync-example}
+
+```json title="Example response" /(since=.*?)\&/ /(limit=.*?)\&/ /(include=.*?)\&/ /(include_own=.*?)\'/
+{
+ "results": [
+ {
+ "type": "subscription",
+ "last_updated": "2026-07-11T15:30:00.1Z",
+ "data": {
+ "sync_id": "4648b4d7-90bf-497c-a5e7-8383d1083d76",
+ "guid": "677ea490-690e-51cb-8b43-755df6c55270",
+ "feed_url": "https://example.com/feed1",
+ "subscribed_at": "2026-07-11T15:30:00.1Z"
+ }
+ },
+ {
+ "type": "subscription",
+ "last_updated": "2026-07-12T12:10:00.1Z",
+ "data": {
+ "sync_id": "aa840671-fc3b-430b-b8f2-92e6e9c7832b",
+ "guid": "a388867e-ce91-54d3-a116-114b07bb84e9",
+ "feed_url": "https://example.com/feed2",
+ "subscribed_at": "2026-03-15T12:06:04.1Z",
+ "unsubscribed_at": "2026-07-12T12:10:00.1Z"
+ }
+ }
+ ],
+ "has_next": true,
+ "next_since": "/api/v1/sync?since=2026-07-12T12:10:00.1Z&limit=2&include=subscriptions&include_own=false"
+}
+```
diff --git a/styles/config/vocabularies/OPA/accept.txt b/styles/config/vocabularies/OPA/accept.txt
new file mode 100644
index 00000000..64b16dce
--- /dev/null
+++ b/styles/config/vocabularies/OPA/accept.txt
@@ -0,0 +1,2 @@
+[nN]amespace
+[bB]oolean