Developers
Publishing to many platforms, as one API
Connect an account once, send a neutral post with a list of destinations, and read back one result per platform — by webhook or on demand. The Relay console calls the same routes your key does, so nearly everything a person does there your code can do too; managing keys and deciding reviews stay with a signed-in admin.
Base URL https://api.relay.rutba.io

Quickstart
From an empty account to a published post
Four steps. The first two happen once; after that, publishing is steps three and four.
- 1
Connect an account
In the console, or over the API for platforms that use a token, app password or webhook URL. For an account somebody else owns, mint a hosted connect link and send it to them.
Connect an accountcurl # Platforms that use a token, app password or webhook URL curl -X POST https://api.relay.rutba.io/v1/connections \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H "content-type: application/json" \ -d '{ "platform": "bluesky", "fields": { "identifier": "you.bsky.social", "app_password": "…" } }' # Or let the account's owner connect it themselves curl -X POST https://api.relay.rutba.io/v1/connect-sessions \ -H "Authorization: Bearer $RELAY_API_KEY" - 2
Create an API key
In the console’s settings. The key is shown once, stored only as a hash, and carries the scopes you give it — a key that only reads cannot publish.
Keys are created in the Relay console, under settings, and listed and revoked there or over the API. Keep a key on your server; it is never meant for a browser. - 3
Publish a post
POST /v1/posts with the content and the destinations, and an Idempotency-Key. The answer is 202, with one delivery per target.
Publish a postcurl curl -X POST https://api.relay.rutba.io/v1/posts \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H "Idempotency-Key: launch-2026-09-15" \ -H "content-type: application/json" \ -d '{ "content": { "text": "Version 4 is out. Here is what changed.", "link": "https://example.com/changelog/4", "media": [{ "id": "med_…", "alt": "The new dashboard" }] }, "platforms": ["bluesky", "mastodon", "telegram"], "scheduled_at": "2026-09-16T09:00", "timezone": "Europe/London" }' - 4
Read the deliveries
Subscribe a webhook, or read the post back. Retry a failed destination on its own.
Read the deliveriescurl # One post and every delivery under it curl https://api.relay.rutba.io/v1/posts/$POST_ID \ -H "Authorization: Bearer $RELAY_API_KEY" # Replay one destination that failed curl -X POST https://api.relay.rutba.io/v1/deliveries/$DELIVERY_ID/retry \ -H "Authorization: Bearer $RELAY_API_KEY"
Conventions
The same rules on every endpoint
Learn them once. Every resource in the reference follows them.
Authentication
Send the key as Authorization: Bearer <key>, or in an X-API-Key header. Keys are scoped and revocable, and an action a key takes on credentials is recorded against it.
Envelopes
Every success is { data, meta } and every failure is { error: { code, message, details } }, with the code from one closed vocabulary. Write one error handler, not one per endpoint.
Idempotency
Send Idempotency-Key on anything that creates. A repeat of the same request within 24 hours returns the original response; the same key with a different body is refused as idempotency_conflict.
Asynchronous by design
Publishing answers 202 Accepted at once. Deliveries settle on their own schedule — watch them with webhooks, or read GET /v1/posts/{id}.
Lenient or strict
By default a post is adapted to each platform and every change is listed in warnings. Send "validation": "strict" to have it refused instead.
Rate limits
Requests are limited per key. Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset, and a refusal is rate_limited.
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "validation_failed",
"message": "reddit needs a subreddit",
"details": { "…": "…" }
}
}HTTP status says how to react in general; the code says what actually happened. Branch on the code.
Webhooks
Signed events you can check in a dozen lines
Register an endpoint and pick its events. Each request is signed with HMAC-SHA256 over {timestamp}.{body} using the endpoint’s secret. Check the signature against the raw body, refuse timestamps outside a few minutes, and deduplicate on the delivery id.
- x-relay-signature
t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">- x-relay-event
- The event name, for routing before parsing.
- x-relay-delivery
- The delivery id. A retry of the same event carries the same id — deduplicate on it.

import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received, before any JSON parsing.
export function isFromRelay(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((pair) => pair.trim().split('=')),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}POST /hooks/relay HTTP/1.1
x-relay-event: post.partial
x-relay-delivery: whd_…
x-relay-signature: t=1757926804,v1=5f0c…
{
"id": "evt_…",
"type": "post.partial",
"created_at": "2026-09-16T08:00:04.512Z",
"data": {
"post_id": "…",
"status": "partial",
"external_id": "launch-42",
"succeeded": 2,
"failed": 1,
"deliveries": [
{ "platform": "bluesky", "status": "succeeded", "remote_url": "https://…" },
{ "platform": "mastodon", "status": "succeeded", "remote_url": "https://…" },
{ "platform": "telegram", "status": "failed", "error_code": "rate_limited" }
]
}
}Events
| Event | Sent when |
|---|---|
| post.created | A post was accepted by the API. |
| post.published | Every delivery for a post succeeded. |
| post.partial | A post reached some platforms and not others. |
| post.failed | A post reached no platform at all. |
| delivery.succeeded | One platform accepted one post. |
| delivery.failed | One platform did not take one post, after every retry. |
| connection.reauth_required | An account stopped accepting posts and needs reconnecting. |
| connection.revoked | An account was disconnected at the platform. |
Every endpoint can send itself a test event, shows its recent delivery attempts, and can have its secret rotated.
Errors
One vocabulary for every platform’s failures
A refusal from any platform is mapped to one of these codes, and the code drives what happens next: the relay retries the transient ones itself, and never retries a refusal.
| Code | HTTP | What happened | What next |
|---|---|---|---|
| validation_failed | 422 | The request is not valid for one or more destinations. | Fix the fields named in details. |
| unauthorized | 401 | No key, or a key that is not valid. | Check the key; create a new one if it was revoked. |
| insufficient_scope | 403 | The key is valid but lacks the scope this needs. | Use a key with the scope named in details. |
| not_found | 404 | No such resource in your organisation. | Another organisation’s resources are never visible. |
| idempotency_conflict | 409 | The key was used before with a different request. | Use a new key for a new request. |
| rate_limited | 429 | Too many requests, from you or at the platform. | Deliveries are retried with backoff; wait for the reset on a request. |
| quota_exceeded | 429 | The plan’s limit for this period is used. | Wait for the period to reset, or change plan. |
| payment_required | 402 | The feature is not part of your plan. | See the pricing page for plans that include it. |
| auth_expired | 401 | A connection’s token lapsed. | Renewed and retried where the platform allows; otherwise reconnect. |
| auth_revoked | 401 | The account withdrew access at the platform. | Reconnect the account. |
| connection_unavailable | 409 | The connection is switched off or needs attention. | Reconnect, or choose another destination. |
| content_rejected | 422 | The platform refused the post. | Not retried — change the content. |
| media_rejected | 422 | The platform refused an attachment. | Not retried — change the media. |
| unsupported_capability | 422 | The destination cannot take what was sent. | Not retried — preview to see what it accepts. |
| duplicate_content | 409 | The platform treated the post as a duplicate. | Not retried. |
| publish_indeterminate | 409 | The post may have landed, and the platform cannot be asked. | Not retried, to avoid a second post — check the account. |
| publishing_paused | 503 | Publishing is paused for this destination. | Deferred and picked up when the pause lifts. |
| platform_unavailable | 503 | The platform is down or not answering. | Retried with backoff. |
| platform_error | 502 | The platform answered with an error the relay could not classify. | Retried with backoff. |
MCP server
Give an agent the relay’s tools — and only the ones it should have
The Relay’s MCP server speaks the Model Context Protocol over stdio, the transport desktop MCP clients use. It is a client of the public API like any other: it carries your API key and nothing more, so the key’s scopes remain the control that cannot be argued around.
Creating is not publishing. create_post makes a draft unless the caller asks to publish now or passes a schedule, so an agent that meant to draft cannot reach an audience by forgetting a flag.

Setting it up
The server is the @rutba/mcp package, run as the rutba-relay-mcp binary. It is not published to a public package registry yet.
- RELAY_API_KEY
- Required. A key from the console; the server refuses to start without one.
- RELAY_BASE_URL
- The API base URL.
- RELAY_MCP_READ_ONLY
- Set to
trueto remove the writing tools from the list entirely. Anything else leaves them in.
{
"mcpServers": {
"rutba-relay": {
"command": "rutba-relay-mcp",
"env": {
"RELAY_API_KEY": "<a key from the console>",
"RELAY_BASE_URL": "https://api.relay.rutba.io",
"RELAY_MCP_READ_ONLY": "true"
}
}
}
}| Tool | Changes anything | What it does |
|---|---|---|
| list_platforms | reads | Every platform, whether it can publish now, and what each accepts. |
| list_connections | reads | The organisation’s authorised accounts. |
| list_posts | reads | Recent posts, newest first, with status and destinations. |
| get_post | reads | One post and every delivery under it. |
| get_post_metrics | reads | What platforms report about a published post, per destination. |
| list_deliveries | reads | One row per destination across all posts. |
| preview_post | reads | Exactly what each platform would receive, without publishing. |
| create_post | writes | Creates a post — a draft, unless asked to publish or schedule. |
| publish_draft | writes | Sends a draft to its destinations now. |
| cancel_post | writes | Cancels a scheduled or queued post before it goes out. |
With read-only mode on, the three writing tools are not offered to the client at all.
Every endpoint, on one page
The reference is rendered from the API’s OpenAPI document, with each operation’s parameters and request fields.