{
  "openapi": "3.1.0",
  "info": {
    "title": "Rutba Relay API",
    "version": "0.2.0",
    "description": "One API to publish a post to many social platforms. Send a neutral post plus a list of targets; the Relay adapts it per platform, publishes asynchronously, and reports each delivery separately.\n\nAuthenticate with an API key from the Relay console: `Authorization: Bearer rsk_live_…`. A key carries scopes (read, publish, connect, admin) and belongs to one organization. API access is part of some plans — `GET /v1/plans` lists which.\n\nErrors are `{ \"error\": { \"code\", \"message\", \"details\" } }` with a lowercase code; successful answers are `{ \"data\", \"meta\" }`."
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key from the Relay console, as `Authorization: Bearer <key>`."
      }
    },
    "schemas": {}
  },
  "paths": {
    "/v1/auth/me": {
      "get": {
        "summary": "The signed-in user, their organisations, and their effective scopes",
        "tags": [
          "auth"
        ],
        "description": "The one call the portal makes on every page load. Shape is stable — the portal builds on it.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/org": {
      "get": {
        "summary": "This organisation",
        "tags": [
          "org"
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/org/members": {
      "get": {
        "summary": "People in this organisation",
        "tags": [
          "org"
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/platforms": {
      "get": {
        "summary": "List every destination and its status",
        "tags": [
          "platforms"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "gated",
                "no_api",
                "read_only"
              ]
            },
            "in": "query",
            "name": "status",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "category",
            "required": false
          },
          {
            "schema": {
              "type": "boolean"
            },
            "in": "query",
            "name": "available",
            "required": false,
            "description": "Only platforms connectable right now"
          }
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/platforms/{id}": {
      "get": {
        "summary": "One destination in detail",
        "tags": [
          "platforms"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/connections": {
      "get": {
        "summary": "List connections",
        "tags": [
          "connections"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "platform",
            "required": false
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "reauth_required",
                "disabled",
                "revoked"
              ]
            },
            "in": "query",
            "name": "status",
            "required": false
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "post": {
        "summary": "Connect an account with a token, app password, or webhook URL",
        "tags": [
          "connections"
        ],
        "description": "For platforms whose `auth.kind` is not `oauth2`. The required `fields` are listed by GET /v1/platforms. OAuth platforms use POST /v1/oauth/{platform}/start instead.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/connections/{id}": {
      "get": {
        "summary": "Get a connection",
        "tags": [
          "connections"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "patch": {
        "summary": "Update a connection",
        "tags": [
          "connections"
        ],
        "description": "Metadata is merged, not replaced. This is how per-connection settings are set — e.g. `{\"metadata\":{\"channel\":\"#general\"}}` for Slack.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "delete": {
        "summary": "Delete a connection",
        "tags": [
          "connections"
        ],
        "description": "Deletes the stored credentials. Deliveries already published stay in history; pending ones for this connection are cancelled.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/connections/{id}/verify": {
      "post": {
        "summary": "Check the credentials still work",
        "tags": [
          "connections"
        ],
        "description": "Calls the platform. Refreshes the cached handle and display name, and flags the connection if the platform has stopped accepting it.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/oauth/{platform}/start": {
      "post": {
        "summary": "Begin an OAuth connect",
        "tags": [
          "connections"
        ],
        "description": "Returns an authorization URL to send the account owner to. The connection appears once they approve and the platform redirects back.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "platform",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/connect-sessions": {
      "post": {
        "summary": "Mint a connect link",
        "tags": [
          "connections"
        ],
        "description": "Returns a single-use URL that lets somebody outside the organisation authorise one account. The link carries no API key and can do nothing else. Send it to the person who holds the platform credentials.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "get": {
        "summary": "List connect links",
        "tags": [
          "connections"
        ],
        "description": "Outstanding first. Tokens are never returned — a link is shown once, at mint.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "used",
                "expired"
              ]
            },
            "in": "query",
            "name": "status",
            "required": false
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/connect-sessions/{id}": {
      "delete": {
        "summary": "Revoke a connect link",
        "tags": [
          "connections"
        ],
        "description": "Kills a link that has not been used yet — the one thing you need when a link went to the wrong chat.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/org/platform-apps": {
      "get": {
        "summary": "This organisation's own platform apps",
        "tags": [
          "org"
        ],
        "description": "The OAuth apps this organisation connects through instead of the shared one. Client secrets are never returned. `supported` lists the platforms that can be given an app today.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "post": {
        "summary": "Configure an app for a platform",
        "tags": [
          "org"
        ],
        "description": "Replaces any existing app for the same platform. The secret is encrypted at rest and never returned.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/org/platform-apps/{id}": {
      "patch": {
        "summary": "Rotate, relabel or switch off an app",
        "tags": [
          "org"
        ],
        "description": "Switching an app off falls back to the shared app for new authorisations. Connections already made through it keep working.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "delete": {
        "summary": "Remove an app",
        "tags": [
          "org"
        ],
        "description": "Connections authorised through it are kept and keep publishing; they can no longer refresh their tokens, so they will ask to be reconnected once the current one expires.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts": {
      "post": {
        "summary": "Publish a post to one or more connections",
        "tags": [
          "posts"
        ],
        "description": "Send `content` plus targets. Targets can be given three ways:\n`targets` (with per-target overrides), `connection_ids`, or `platforms`\n(every active connection on those platforms).\n\nReturns 202 with a post id and one delivery per target. Deliveries settle\nindependently — watch them with webhooks or GET /v1/posts/{id}.\n\nSend an `Idempotency-Key` header. A repeat of the same request returns the\noriginal response instead of publishing twice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "object",
                    "properties": {
                      "text": {
                        "type": "string"
                      },
                      "title": {
                        "type": "string",
                        "description": "Used by platforms with a real title field (Reddit, WordPress)."
                      },
                      "link": {
                        "type": "string",
                        "format": "uri"
                      },
                      "tags": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "media": {
                        "type": "array",
                        "items": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "string",
                              "description": "An asset from POST /v1/media"
                            },
                            "url": {
                              "type": "string",
                              "format": "uri",
                              "description": "Or a public URL we fetch"
                            },
                            "alt": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    }
                  },
                  "targets": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "connection_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "platforms": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "overrides": {
                    "type": "object",
                    "description": "Per-platform extras, keyed by platform id."
                  },
                  "scheduled_at": {
                    "type": "string",
                    "description": "An ISO instant (\"2026-08-17T04:00:00Z\"), or a local wall clock (\"2026-08-17T09:00\") read in `timezone`."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA zone for a wall-clock `scheduled_at`, e.g. \"Asia/Karachi\". Defaults to the organisation's. Ignored when `scheduled_at` carries its own offset."
                  },
                  "validation": {
                    "type": "string",
                    "enum": [
                      "strict",
                      "lenient"
                    ],
                    "description": "lenient (default) adapts the post per platform — truncating text, dropping media a platform will not take. strict refuses instead."
                  },
                  "external_id": {
                    "type": "string"
                  },
                  "draft": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "header",
            "name": "idempotency-key",
            "required": false,
            "description": "Unique per logical publish. Remembered for 24 hours."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "get": {
        "summary": "List posts",
        "tags": [
          "posts"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "status",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "external_id",
            "required": false
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "in": "query",
            "name": "limit",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "cursor",
            "required": false,
            "description": "A post id from the previous page."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/preview": {
      "post": {
        "summary": "See what a post becomes on each platform, without publishing",
        "tags": [
          "posts"
        ],
        "description": "Runs the same projection the worker publishes through, against the same\nconnections, and writes nothing: no post, no deliveries, no queue job, no\nmetered usage, no media fetch.\n\nReturns per target the text as that platform would receive it, which media\nit will not take and why, and every warning. This is what a composer draws.\n\nMedia given as a `url` is not downloaded — its type is assumed from the URL\nand its size is not checked. Those refs come back in `unverified_media` so\nthe preview never claims to have looked at something it has not.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "object"
                  },
                  "targets": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "connection_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "platforms": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "overrides": {
                    "type": "object"
                  },
                  "validation": {
                    "type": "string",
                    "enum": [
                      "strict",
                      "lenient"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/calendar": {
      "get": {
        "summary": "Scheduled posts in a date range",
        "tags": [
          "posts"
        ],
        "description": "`from` and `to` are local calendar dates (YYYY-MM-DD) in the requested\ntimezone, and the range covers whole local days — a report for \"16 August\"\nin Karachi starts at 19:00 UTC on the 15th, and one that ignored that would\nbe wrong by five hours at both ends.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "from",
            "required": true,
            "description": "Local date, YYYY-MM-DD."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "to",
            "required": true,
            "description": "Local date, YYYY-MM-DD. Inclusive."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "status",
            "required": false,
            "description": "Comma-separated post statuses. Defaults to scheduled and queued."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "timezone",
            "required": false,
            "description": "IANA zone. Defaults to yours, then the organisation's."
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 200
            },
            "in": "query",
            "name": "limit",
            "required": false
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}": {
      "get": {
        "summary": "Get a post and its deliveries",
        "tags": [
          "posts"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "patch": {
        "summary": "Edit a draft or a scheduled post",
        "tags": [
          "posts"
        ],
        "description": "Drafts and scheduled posts only. A published post cannot be edited — the\nrelay cannot reach into fifteen networks and change what is already there —\nso it is refused with a pointer at `POST /v1/posts/{id}/duplicate`.\n\nThe post is re-projected against every target, so a target that was refused\nat validation gets another chance: the edit may be the fix. Changing the\ntargets rewrites the deliveries; leaving them out keeps the ones there are.\n\n`scheduled_at: null` unschedules, turning a scheduled post back into a draft.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "object"
                  },
                  "targets": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "connection_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "platforms": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "overrides": {
                    "type": "object"
                  },
                  "scheduled_at": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "timezone": {
                    "type": "string"
                  },
                  "external_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "validation": {
                    "type": "string",
                    "enum": [
                      "strict",
                      "lenient"
                    ]
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "delete": {
        "summary": "Cancel the deliveries not yet sent",
        "tags": [
          "posts"
        ],
        "description": "Only pending deliveries are cancelled. Anything already published stays published — the relay cannot un-post.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/metrics": {
      "get": {
        "summary": "Engagement for one post, per delivery and totalled",
        "tags": [
          "posts"
        ],
        "description": "The most recent reading collected for each of the post's deliveries.\n\nEvery field is nullable and a null is not a zero: platforms report different\nthings, and most report no impressions at all. A total is absent when no\nplatform supplied that number, rather than being summed as zero — \"nobody\ncounted\" and \"nobody saw it\" are different answers.\n\nReadings are collected on a schedule that decays with the post's age and\nstops after a week, so `next_collection_at` is null for an older post and\n`collected_at` is when the platform was last asked, not when it counted.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/publish": {
      "post": {
        "summary": "Release a draft now",
        "tags": [
          "posts"
        ],
        "description": "Queues every pending delivery immediately. A scheduled post can be released\nearly this way — its delayed jobs are removed first, so it goes out once\nrather than now and again at the time nobody wants any more.\n\nTargets refused at validation stay refused: publishing is not a retry, and\nsending content a platform already rejected would fail identically.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/schedule": {
      "post": {
        "summary": "Set or move a post's scheduled time",
        "tags": [
          "posts"
        ],
        "description": "Works on a draft and on an already-scheduled post: the delayed job is\nremoved and re-added, because BullMQ ignores an add for a job it already\nholds and the time would otherwise silently not move.\n\n`scheduled_at` takes an ISO instant (\"2026-08-17T04:00:00Z\") or a local wall\nclock (\"2026-08-17T09:00\") read in `timezone`, then yours, then the\norganisation's. When the clocks change under that local time the response\ncarries a `schedule_notice` saying how it was resolved.\n\nA delivery already being published is left exactly as it is and reported in\n`skipped` — it is too late to move a post that is going out right now.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "scheduled_at"
                ],
                "properties": {
                  "scheduled_at": {
                    "type": "string"
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA zone, e.g. \"Asia/Karachi\"."
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/duplicate": {
      "post": {
        "summary": "Copy a post as a new draft",
        "tags": [
          "posts"
        ],
        "description": "Reposting is the most common thing a customer does after publishing. The\ncopy is a draft aimed at the same accounts, and nothing is sent until you\ncall publish or schedule.\n\nTargets whose connection is no longer usable are dropped and listed in\n`dropped_targets` — a duplicate of a post from June should not look ready\nto send to an account that was revoked in July.\n\n`external_id` is not copied: it names one record in your system, and two\nposts claiming it would break every lookup by it.\n\nThe body is optional — a bare POST duplicates as a plain draft. It may\ncarry `external_id`, `source`, `scheduled_at` and `timezone`.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/retry": {
      "post": {
        "summary": "Retry the failed deliveries of a post",
        "tags": [
          "posts"
        ],
        "description": "Requeues deliveries in `failed` state. Deliveries in `rejected` state are not retried — the platform refused the content, and sending it again would fail identically.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "summary": "Deliveries used this billing period",
        "tags": [
          "billing"
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/submit": {
      "post": {
        "summary": "Submit a post for review",
        "tags": [
          "posts"
        ],
        "description": "Moves a draft or scheduled post to `pending_approval`, where it cannot be published until somebody decides. Submitting twice leaves one open review rather than two.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/posts/{id}/approvals": {
      "get": {
        "summary": "Every review round on a post",
        "tags": [
          "posts"
        ],
        "description": "Newest first. A row with no `decided_at` is an open review. \"Rejected twice before it went out\" is a question this answers and a single status column cannot.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/deliveries": {
      "get": {
        "summary": "The delivery log across every post",
        "tags": [
          "posts"
        ],
        "description": "One row per (post, destination), newest first. Filterable by platform,\nstatus, post, connection and date, and keyset paginated with `cursor`.\n\n`status` and `platform` accept comma-separated lists — \"which Instagram and\nThreads deliveries failed this week\" is one request, not two.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "platform",
            "required": false,
            "description": "Platform id, or several comma-separated."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "status",
            "required": false,
            "description": "pending, processing, succeeded, failed, rejected, skipped, cancelled — or several comma-separated."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "post_id",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "connection_id",
            "required": false
          },
          {
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "in": "query",
            "name": "from",
            "required": false
          },
          {
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "in": "query",
            "name": "to",
            "required": false
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "in": "query",
            "name": "limit",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "cursor",
            "required": false,
            "description": "A delivery id from the previous page."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/deliveries/{id}": {
      "get": {
        "summary": "Get one delivery",
        "tags": [
          "posts"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/deliveries/{id}/retry": {
      "post": {
        "summary": "Retry one destination",
        "tags": [
          "posts"
        ],
        "description": "Requeues a single failed delivery, leaving every other destination of the\nsame post alone — including ones that succeeded, which is the whole point:\neight platforms took the post and Reddit was rate-limited.\n\nA `rejected` delivery is not retried. The platform refused the content, and\nsending the same bytes again fails identically; edit the post instead.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/analytics/summary": {
      "get": {
        "summary": "Headline numbers for a period",
        "tags": [
          "analytics"
        ],
        "description": "Deliveries by status, posts by status, and the success rate over them.\n\n`from` and `to` take either a local calendar date (YYYY-MM-DD, resolved to\nwhole days in `timezone`) or an ISO instant. Defaults to the last 30 days.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "from",
            "required": false,
            "description": "Local date (2026-08-01) or ISO instant. Defaults to 30 days ago."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "to",
            "required": false,
            "description": "Local date or ISO instant. Defaults to now."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "timezone",
            "required": false,
            "description": "IANA zone. Defaults to yours, then the organisation's."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/analytics/platforms": {
      "get": {
        "summary": "Success rate per platform",
        "tags": [
          "analytics"
        ],
        "description": "One row per destination, worst success rate first — the ordering a customer is looking for, because the reason to open this page is that something is broken.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "from",
            "required": false,
            "description": "Local date (2026-08-01) or ISO instant. Defaults to 30 days ago."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "to",
            "required": false,
            "description": "Local date or ISO instant. Defaults to now."
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "timezone",
            "required": false,
            "description": "IANA zone. Defaults to yours, then the organisation's."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/analytics/timeseries": {
      "get": {
        "summary": "Deliveries over time, by status",
        "tags": [
          "analytics"
        ],
        "description": "Buckets are local to `timezone`, so a daily chart breaks at the customer's\nmidnight rather than at UTC's — five hours out for a Karachi tenant, which\nis enough to move an evening campaign into the wrong day.\n\nEmpty buckets are filled in. A chart that skips a day with no deliveries\ndraws a line straight through it and hides the outage.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "from",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "to",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "timezone",
            "required": false
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "hour",
                "day",
                "week",
                "month"
              ],
              "default": "day"
            },
            "in": "query",
            "name": "granularity",
            "required": false
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/templates": {
      "get": {
        "summary": "List templates",
        "tags": [
          "templates"
        ],
        "parameters": [
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "in": "query",
            "name": "limit",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "cursor",
            "required": false,
            "description": "A template id from the previous page."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "post": {
        "summary": "Save a template",
        "tags": [
          "templates"
        ],
        "description": "`content` takes the same shape as a post's, and `default_targets` the same\nselectors — so applying a template is a spread into POST /v1/posts rather\nthan a translation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "content"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "content": {
                    "type": "object"
                  },
                  "default_targets": {
                    "type": "object",
                    "properties": {
                      "platforms": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "connection_ids": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "overrides": {
                    "type": "object",
                    "description": "Per-platform extras, keyed by platform id."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/templates/{id}": {
      "get": {
        "summary": "Get one template",
        "tags": [
          "templates"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "patch": {
        "summary": "Update a template",
        "tags": [
          "templates"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "content": {
                    "type": "object"
                  },
                  "default_targets": {
                    "type": "object"
                  },
                  "overrides": {
                    "type": "object"
                  }
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "delete": {
        "summary": "Delete a template",
        "tags": [
          "templates"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/media": {
      "post": {
        "summary": "Upload an image or video",
        "tags": [
          "media"
        ],
        "description": "multipart/form-data with a `file` part, and optionally `alt`. Returns an asset id to use in a post’s `media` array. The same bytes uploaded twice by one organization return the same asset. The largest file accepted is the plan’s media limit (`GET /v1/billing`).",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "get": {
        "summary": "List uploaded assets",
        "tags": [
          "media"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "enum": [
                "image",
                "video",
                "audio"
              ]
            },
            "in": "query",
            "name": "kind",
            "required": false
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "in": "query",
            "name": "limit",
            "required": false
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "cursor",
            "required": false
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/media/id/{id}": {
      "get": {
        "summary": "Get one asset",
        "tags": [
          "media"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "delete": {
        "summary": "Delete an asset",
        "tags": [
          "media"
        ],
        "description": "Removes the asset. The stored file goes when no other asset of this organization refers to it; posts already published keep what the platform holds.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "summary": "List webhook endpoints",
        "tags": [
          "webhooks"
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      },
      "post": {
        "summary": "Register a webhook endpoint",
        "tags": [
          "webhooks"
        ],
        "description": "The signing secret is returned once and cannot be read back.\n\nEach request carries `X-Relay-Signature: t={unix},v1={hmac}` where the HMAC is\nSHA-256 over `{t}.{raw body}` keyed with the secret. Verify it and reject a\ntimestamp older than five minutes.",
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/webhooks/{id}/rotate": {
      "post": {
        "summary": "Issue a new signing secret",
        "tags": [
          "webhooks"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/webhooks/{id}/test": {
      "post": {
        "summary": "Send a test event to this endpoint",
        "tags": [
          "webhooks"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/webhooks/{id}/deliveries": {
      "get": {
        "summary": "Recent delivery attempts for this endpoint",
        "tags": [
          "webhooks"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "delete": {
        "summary": "Delete a webhook endpoint",
        "tags": [
          "webhooks"
        ],
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/v1/plans": {
      "get": {
        "summary": "The Relay’s plans and what each includes",
        "tags": [
          "billing"
        ],
        "security": [],
        "description": "The price list, read from the catalogue record rutba.io shows: each card’s name, price and words, and the Relay limits and features of the plan it sells.\nA `price_minor` of null means on request. A `limits` of null means the card sells no plan this API enforces (an enterprise contract).\nAnswers without a credential.",
        "responses": {
          "200": {
            "description": "`{ data: { product, plans: [{ code, card, name, price_minor, currency, unit, summary, features, featured, limits }] }, meta: { currency } }`"
          }
        }
      }
    },
    "/v1/billing": {
      "get": {
        "summary": "This organization’s plan, limits and usage this month",
        "tags": [
          "billing"
        ],
        "responses": {
          "200": {
            "description": "Default Response"
          }
        },
        "description": "Read from the organization’s plan record on every request: how it is on the plan (a subscription, a licence, or assigned by Rutba), its limits (-1 is unlimited), the features it carries, and posts and deliveries used this month. A post counts once, when its first destination takes it."
      }
    }
  },
  "servers": [
    {
      "url": "https://api.relay.rutba.io"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "auth",
      "description": "Who a key belongs to"
    },
    {
      "name": "org",
      "description": "The organization a key acts for, and its people"
    },
    {
      "name": "platforms",
      "description": "What the relay can publish to"
    },
    {
      "name": "connections",
      "description": "Authorised accounts"
    },
    {
      "name": "posts",
      "description": "Publishing"
    },
    {
      "name": "templates",
      "description": "Reusable content presets"
    },
    {
      "name": "analytics",
      "description": "Aggregates over deliveries"
    },
    {
      "name": "media",
      "description": "Uploads"
    },
    {
      "name": "webhooks",
      "description": "Event delivery"
    },
    {
      "name": "billing",
      "description": "Plans and usage"
    }
  ]
}
