Skip to content
This is a core endpoint. All implementing servers and clients MUST support it.

Subscription API specification

Subscriptions represent the relationship between a user and a podcast feed.

Subscriptions are at the heart of the Open Podcast API. They represent which feeds a user has subscribed to, both presently and historically.

The subscriptions endpoint is designed to give clients a simple interface for synchronizing a user’s podcast subscriptions. It aims to support:

  • Offline-first operation
  • Deterministic identifiers
  • Idempotent operations
  • Efficient incremental synchronization
  • Multi-device consistency

The Podcast 2.0 specification presents developers with stable identifiers (podcast:guid), which are UUIDv5 values that can be calculated from the feed URL using a standard-supplied namespace. However, the original podcast specification makes no such guarantees. This makes implementing cross-device synchronization difficult, as developers need to use unstable fields to determine which feed is being targeted.

To resolve this, the Open Podcast API makes use of the same deterministic UUID resolution outlined in the Podcast Index documentation1 and requires Clients to provide a calculated UUID value with every feed.

The key words “MUST”, “MUST NOT”, “SHOULD”, “SHOULD NOT”, and “MAY” in this document are to be interpreted as described in RFC 21192.

The following terms are also used throughout this document:

Client
Software that sends HTTP requests to a conforming server.
Server
An implementation that exposes the endpoints defined in this specification.
User
The authenticated principal performing requests.
Feed
A shared resource representing a podcast feed.
Subscription
A user-owned resource containing details about a User’s subscription to a Feed.
Action
An operation performed against a subscription resource.
Cursor
An opaque token used to resume synchronization.

Timestamps MUST be conform to RFC 33393 and be submitted in UTC.

All request and response bodies MUST be encoded as UTF-8 JSON.

This specification uses the following identifier formats:

  • UUID version 4 for client identifiers as defined in RFC 95624
  • UUID version 5 for deterministic resource identifiers as defined in RFC 95624
  • Base64 encoding for cursors

This specification defines:

  • Resource identifiers
  • Action submission semantics
  • Synchronization mechanisms
  • Conflict resolution rules
  • Client and server behavior

This document does not define:

  • User authentication mechanisms
  • Feed metadata ingestion
  • Client user interface behavior

The system consists of:

  • Client devices
  • An HTTP API server

Clients MAY operate without network connectivity and queue actions locally.

Queued actions MUST be transmitted to the server when connectivity is restored.

Synchronization is based on an append-only action log.

Clients retrieve new actions using a cursor-based incremental synchronization mechanism.

Feeds are identified using deterministic UUIDv5 identifiers derived from podcast feed URLs. Clients MUST provide a valid UUIDv5 identifier for all feed objects. This UUID value must be determined by ONE of the following methods, in order of preference:

  1. Using the podcast:guid value of the feed’s RSS file, if it is a valid UUID OR,
  2. Calculating a UUIDv5 value using the normalized feed_url.

To calculate the UUID value, the client MUST do the following:

  1. Normalize the feed_url by removing the scheme (for example: https://) and all trailing slashes (/).
  2. Calculate the UUID using the normalized feed_url and the podcast namespace UUID: ead4c236-bf58-58c6-a2c6-a6b28d128cb6.

See the Podcast Index’s Guid documentation for more information.1

import uuid
import re
def calculate_uuid(feed_url):
PODCAST_NAMESPACE = uuid.UUID("ead4c236-bf58-58c6-a2c6-a6b28d128cb6")
sanitized_feed_url = re.sub(r'^[a-zA-Z]+://', '', feed_url).rstrip('/')
return uuid.uuid5(PODCAST_NAMESPACE, sanitized_feed_url)

Running the above example with the feed URL "https://podnews.net/rss/" will yield 9b024349-ccf0-5f69-a609-6b82873eab3c.

Subscriptions are considered valid even if the User has unsubscribed from the feed. Unsubscribing is a non-destructive action that leaves the subscription entry intact.

A User is “subscribed” to a Feed when they:

  1. Have a Subscription entry for the Feed AND
  2. The unsubscribed_at timestamp is null.

Clients may submit an update to a Subscription with a null unsubscribed_at timestamp to resubscribe a user to a feed.

A Feed represents a shared logical resource corresponding to a podcast RSS feed. Feeds are uniquely identified by a deterministic UUID derived from the normalized feed URL and a podcast namespace UUID.

A Feed resource MAY exist independently of any Subscriptions but MAY also be created implicitly when a Subscription is submitted.

FieldTypeRequiredDescription
uuidUUIDYesDeterministic identifier for the feed
feed_urlstringYesThe RSS feed’s canonical URL used to calculate the UUID
created_atstring (RFC3339)YesServer-authoritative creation timestamp
updated_atstring (RFC3339)YesServer-authoritative update timestamp

A Subscription represents a user’s subscription to a given Feed.

Each User MAY have at most one Subscription per Feed.

A Subscription is uniquely identified by the tuple:

(user, feed_uuid)

Clients do not directly access Subscription identifiers. Subscriptions are accessed via the Feed resource.

FieldTypeRequiredDescription
subscribed_atstring (RFC3339)YesClient-provided subscription timestamp, if submitted. Implicitly created by the server if absent
unsubscribed_atstring (RFC3339)NoClient-provided unsubscription timestamp, if submitted.
created_atstring (RFC3339)YesServer-authoritative creation timestamp
updated_atstring (RFC3339)YesServer-authoritative update timestamp

Normative rule: created_at and updated_at are managed by the server. Clients MAY supply subscribed_at and unsubscribed_at in requests but it doesn’t override the server’s canonical timestamps.

POST /api/v1/subscriptions

Clients use this endpoint to submit subscription actions.

This endpoint supports the submission of actions for Subscriptions. Each action MUST reference a Feed.

Each object in a request payload MUST reference an action. The supported actions for this endpoint are:

create
Create a new subscription for the authenticated User and the referenced Feed
update
Update the subscription details for an authenticated User and a referenced Feed

Each handled item in a POST request to this endpoint MUST be returned in the response. To inform the Client, each object MUST contain a status field matching the following enumerable values:

created
The subscription was created successfully
updated
The subscription was updated successfully
conflict
A subscription for the requesting User to the provided Feed exists already
duplicate
The payload object is a duplicate of another update in the same payload
invalid_action
The payload object referenced an invalid action
malformed_feed_uuid
The UUID value in the Feed payload is malformed
malformed_feed_url
The URL in the Feed payload is not a valid URI value
transient_server_error
The Server could not perform the update due to a transient issue such as database connection issues

Requests sent to this endpoint MUST conform to the following:

  1. All requests MUST be submitted as an array of objects, with at least one and at most 30 items.
  2. Each item in the array MUST include all required fields.

Servers MUST immediately reject any invalid payload with a 400 response.

FieldTypeRequiredDescription
dataarrayYesThe array of data submitted to the server
data.uuidUUIDYesThe Client-generated UUIDv4 identifier for the action
data[].actionstringYesThe supported action being taken against the subscription
data[].feedobjectYesDetails about the Feed that the subscription targets
data[].feed.uuidUUIDYesThe calculated UUIDv5 identifier for the Feed
data[].feed.feed_urlstringYesThe canonical URL of the feed RSS file
data[].dataobjectYesThe data object containing subscription information with at least one value
data[].data.subscribed_atstring (RFC3339)NoThe timestamp at which the subscription was created
data[].data.unsubscribed_atstring (RFC3339) or nullNoThe timestamp at which the user unsubscribed from the feed

If all fields in the request payload are valid, the Server MUST respond with a 202 status and return a payload with an object corresponding to each action submitted.

FieldTypeRequiredDescription
dataarrayYesThe array of response objects
data.uuidUUIDYesThe Client-generated UUIDv4 identifier for the action
data.statusstringYesThe Server-authoritative response status
data.receivedstring (RFC3339)YesThe Server-authoritative timestamp at which the request was received
data[].feedobjectNoThe referenced Feed item for the action
data[].feed.uuidUUIDYesThe calculated UUIDv5 identifier for the feed
data[].feed.feed_urlstringYesThe canonical URL of the feed RSS file
data[].feed.created_atstring (RFC3339)YesThe Server-authoritative creation timestamp for the Feed entity
data[].feed.updated_atstring (RFC3339)NoThe Server-authoritative last update timestamp for the Feed entity
data[].subscriptionobjectNoThe Subscription entity
data[].subscription.subscribed_atstring (RFC3339)NoThe timestamp at which the User subscribed to the Feed
data[].subscription.unsubscribed_atstring (RFC3339)NoThe timestamp at which the User subscribed to the Feed
data[].subscription.created_atstring (RFC3339)YesThe Server-authoritative creation timestamp for the Subscription entity
data[].subscription.updated_atstring (RFC3339)YesThe Server-authoritative update timestamp for the Subscription entity

The Client MUST follow these rules when submitting a request to this endpoint:

  1. The Client MUST NOT submit more than 30 items in a single payload.
  2. The Client MUST generate a random UUID for each action in the payload.
  3. The Client MUST await a response from the Server before submitting a new request.
  4. The Client SHOULD inform the User of any failures that were received in the response.
  5. The Client MAY retry items that failed with a status of transient_server_error.
  6. The Client MUST NOT retry items that failed with a status of invalid_action.
  7. The Client MUST NOT retry items that failed with a status of malformed_uuid.
  8. The Client MUST NOT retry items that failed with a status of malformed_feed_url.
  9. The Client MAY use the updated_at timestamp of the Subscription to communicate to a user when the subscription was made active again.

The Server MUST keep all action requests in a centralized append-only log format. The Server MAY compact this data to retain only the latest action of a given type.

The Server MUST update the materialized view of updated entities and return their data in response to updates.

The Server MUST follow these rules when processing a request to this endpoint:

  1. The Server MUST respond with a 400 error if the payload doesn’t contain all required fields.
  2. The Server MUST respond with a 400 error if the payload contains more than 30 or fewer than 1 items.
  3. The Server MUST NOT attempt to process any action that fails validation.
  4. The Server MUST process all objects in the response and return a corresponding object in the response.
  5. The Server MUST discard any duplicate object from the payload and process only one version of the action.
  6. The Server MUST create a corresponding object for all submitted actions and respond with an array matching the length of the submission.
  7. The Server MUST implicitly create a Feed for all actions that reference a non-extant Feed.

For each Feed:

  1. The Server MUST generate a created_at timestamp recording the date and time at which the Feed was added to the system.
  2. The Server MUST generate an updated_at timestamp recording the date and time at which the Feed was last modified.

For each Subscription:

  1. The Server MUST generate a created_at timestamp recording the date and time at which the Subscription was added to the system.
  2. The Server MUST generate an updated_at timestamp recording the date and time at which the Subscription was last modified.
  3. The Server SHOULD generate a subscribed_at timestamp matching the created_at timestamp if no subscribed_at field is received in the creation payload.
  4. The Server MUST NOT add an unsubscribed_at timestamp unless one is sent by the Client.
Request
{
"data": [
// Subscribe to a feed
{
"uuid": "329e6b8f-a540-4c6e-9ba0-2996e0352736",
"action": "create",
"feed": {
"uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf",
"feed_url": "https://example.com/feed1.rss/"
},
"data": {
"subscribed_at": "2026-03-16T05:20:48.000Z"
}
},
// Resubscribe to a feed
{
"uuid": "987f1cad-807f-4c00-88aa-277fd470697a",
"action": "update",
"feed": {
"uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44",
"feed_url": "https://example.com/feed2.rss/"
},
"data": {
"unsubscribed_at": null
}
},
// Unsubscribe from a feed
{
"uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8",
"action": "update",
"feed": {
"uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7",
"feed_url": "https://example.com/feed3.rss/"
},
"data": {
"unsubscribed_at": "2026-03-16T05:21:48.000Z"
}
},
// Invalid action
{
"uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e",
"action": "unsupported",
"feed": {
"uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d",
"feed_url": "https://example.com/feed4.rss/"
},
"data": {
"subscribed_at": "2026-03-16T06:00:02.000Z",
}
},
// Invalid feed UUID
{
"uuid": "4c92e4d0-ba1a-497c-83d8-b0c469d4e1be",
"action": "create",
"feed": {
"uuid": "not-a-uuid",
"feed_url": "https://example.com/feed5.rss/"
},
"data": {
"subscribed_at": "2026-03-16T06:05:02.000Z"
}
}
]
}
Response
{
"data": [
{
"uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d",
"status": "created",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf",
"feed_url": "https://example.com/feed1.rss/",
"created_at": "2026-03-16T06:05:02.000Z",
"updated_at": "2026-03-16T06:05:02.000Z"
},
"subscription": {
"subscribed_at": "2026-03-16T05:20:48.000Z",
"created_at": "2026-03-16T06:05:02.000Z",
"updated_at": "2026-03-16T06:05:02.000Z"
}
},
{
"uuid": "987f1cad-807f-4c00-88aa-277fd470697a",
"status": "updated",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44",
"feed_url": "https://example.com/feed2.rss/",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-15T03:05:01:000Z"
},
"subscription": {
"subscribed_at": "2026-03-15T03:05:01:000Z",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-16T06:05:02:000Z"
}
},
{
"uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8",
"status": "updated",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7",
"feed_url": "https://example.com/feed3.rss/",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-15T03:05:01:000Z"
},
"subscription": {
"subscribed_at": "2026-03-15T03:05:01:000Z",
"unsubscribed_at": "2026-03-16T05:21:48.000Z",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-16T06:05:02:000Z"
}
},
{
"uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e",
"status": "invalid_action",
"received": "2026-03-16T06:05:02:000Z",
},
{
"uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e",
"status": "malformed_feed_uuid",
"received": "2026-03-16T06:05:02:000Z",
}
]
}
GET /api/v1/subscriptions?cursor={cursor}&page_size=30&direction={direction}&include_errors=false

Clients use this endpoint to request actions that have been submitted to the server since the provided cursor.

This endpoint returns a list of valid and applied actions taken on an authenticated principal’s Subscriptions. Clients may use this endpoint to fetch a list of updates to Subscriptions since they last came online.

ParameterTypeInRequiredDescription
cursorstringQueryNoThe Base64-encoded cursor to query from
page_sizenumberQueryNoThe number of results to return per-page
directionstringQueryNoThe direction in which to search for results. ascending (default) or descending.
include_errorsbooleanQueryNoWhether to include invalid actions (default false)

The Server MUST respond to valid requests with a 200 status.

FieldTypeRequiredDescription
next_cursorstringNoThe Base64-encoded cursor for the next page of results
prev_cursorstringYesThe Base64-encoded cursor for the current page of results
has_nextbooleanNoWhether there are more results for the given request
dataarrayYesThe array of response objects
data.uuidUUIDYesThe Client-generated UUIDv4 identifier for the action
data.statusstringYesThe Server-authoritative response status
data.receivedstring (RFC3339)YesThe Server-authoritative timestamp at which the request was received
data[].feedobjectNoThe referenced Feed item for the action
data[].feed.uuidUUIDYesThe calculated UUIDv5 identifier for the feed
data[].feed.feed_urlstringYesThe canonical URL of the feed RSS file
data[].feed.created_atstring (RFC3339)YesThe Server-authoritative creation timestamp for the Feed entity
data[].feed.updated_atstring (RFC3339)NoThe Server-authoritative last update timestamp for the Feed entity
data[].subscriptionobjectNoThe Subscription entity
data[].subscription.subscribed_atstring (RFC3339)NoThe timestamp at which the User subscribed to the Feed
data[].subscription.unsubscribed_atstring (RFC3339)NoThe timestamp at which the User subscribed to the Feed
data[].subscription.created_atstring (RFC3339)YesThe Server-authoritative creation timestamp for the Subscription entity
data[].subscription.updated_atstring (RFC3339)YesThe Server-authoritative update timestamp for the Subscription entity
  1. The Client MAY provide any combination of supported query parameters, or none.
  2. The Client SHOULD compare results in the response against its internal state to resolve the latest state of the User’s Subscriptions.
  1. The Server MUST discard invalid query parameters and use default parameters.
  2. The Server MUST calculate and encode a cursor value for the given request using the provided parameters, or default parameters.
  3. The Server MUST NOT return any actions that were not applied due to errors, unless the include_errors parameter is true.
  4. The Server MAY use any method to calculate a cursor provided it meets the following criteria:
    1. The cursor MUST contain at least one ordered parameter. For example, received timestamp or incremental database IDs.
    2. The cursor MUST NOT contain any sensitive data.
    3. The cursor MUST be Base64-encoded.
  5. The Server MUST return actions relating to the authenticated principal only. The Server MUST NOT return any actions associated with other users.
  6. The Server SHOULD set sensible default values for any parameters whose default is not explicitly stated in this document.
  7. The Server MUST return at most the number of results specified in the page_size parameter.
Terminal window
curl -X GET "https://opa-server.test/api/v1/subscriptions?page_size=50"
Response
{
"data": [
{
"uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d",
"status": "created",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf",
"feed_url": "https://example.com/feed1.rss/",
"created_at": "2026-03-16T06:05:02.000Z",
"updated_at": "2026-03-16T06:05:02.000Z"
},
"subscription": {
"subscribed_at": "2026-03-16T05:20:48.000Z",
"created_at": "2026-03-16T06:05:02.000Z",
"updated_at": "2026-03-16T06:05:02.000Z"
}
},
{
"uuid": "987f1cad-807f-4c00-88aa-277fd470697a",
"status": "updated",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44",
"feed_url": "https://example.com/feed2.rss/",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-15T03:05:01:000Z"
},
"subscription": {
"subscribed_at": "2026-03-15T03:05:01:000Z",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-16T06:05:02:000Z"
}
},
{
"uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8",
"status": "updated",
"received": "2026-03-16T06:05:02:000Z",
"feed": {
"uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7",
"feed_url": "https://example.com/feed3.rss/",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-15T03:05:01:000Z"
},
"subscription": {
"subscribed_at": "2026-03-15T03:05:01:000Z",
"unsubscribed_at": "2026-03-16T05:21:48.000Z",
"created_at": "2026-03-15T03:05:01:000Z",
"updated_at": "2026-03-16T06:05:02:000Z"
}
},
{
"uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e",
"status": "invalid_action",
"received": "2026-03-16T06:05:02:000Z",
},
{
"uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e",
"status": "malformed_feed_uuid",
"received": "2026-03-16T06:05:02:000Z",
}
],
"prev_cursor": "aWQ9MXxwYWdlX3NpemU9MzA=",
"has_next": false
}
  1. https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/tags/guid.md 2

  2. https://www.rfc-editor.org/rfc/rfc2119

  3. https://www.rfc-editor.org/rfc/rfc3339

  4. https://www.rfc-editor.org/rfc/rfc9562 2