Skip to content
Rutba Relay
Get started

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

A drawing of an API reference: a column of section names beside four operations, POST /v1/posts, GET and PATCH /v1/posts/{id}, and DELETE /v1/webhooks/{id}, each with its method in a coloured pill.

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. 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. 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. 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. 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.

Every failure has this shapejson
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.
A drawing of a webhook body, a secret key and a timestamp all feeding an HMAC SHA-256 seal, which a receiver compares and accepts with a tick; beneath, the x-relay-signature header with its t and v1 parts.
Verify a requestnode
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);
}
A post.partial eventhttp
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

EventSent when
post.createdA post was accepted by the API.
post.publishedEvery delivery for a post succeeded.
post.partialA post reached some platforms and not others.
post.failedA post reached no platform at all.
delivery.succeededOne platform accepted one post.
delivery.failedOne platform did not take one post, after every retry.
connection.reauth_requiredAn account stopped accepting posts and needs reconnecting.
connection.revokedAn 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.

CodeHTTPWhat happenedWhat next
validation_failed422The request is not valid for one or more destinations.Fix the fields named in details.
unauthorized401No key, or a key that is not valid.Check the key; create a new one if it was revoked.
insufficient_scope403The key is valid but lacks the scope this needs.Use a key with the scope named in details.
not_found404No such resource in your organisation.Another organisation’s resources are never visible.
idempotency_conflict409The key was used before with a different request.Use a new key for a new request.
rate_limited429Too many requests, from you or at the platform.Deliveries are retried with backoff; wait for the reset on a request.
quota_exceeded429The plan’s limit for this period is used.Wait for the period to reset, or change plan.
payment_required402The feature is not part of your plan.See the pricing page for plans that include it.
auth_expired401A connection’s token lapsed.Renewed and retried where the platform allows; otherwise reconnect.
auth_revoked401The account withdrew access at the platform.Reconnect the account.
connection_unavailable409The connection is switched off or needs attention.Reconnect, or choose another destination.
content_rejected422The platform refused the post.Not retried — change the content.
media_rejected422The platform refused an attachment.Not retried — change the media.
unsupported_capability422The destination cannot take what was sent.Not retried — preview to see what it accepts.
duplicate_content409The platform treated the post as a duplicate.Not retried.
publish_indeterminate409The post may have landed, and the platform cannot be asked.Not retried, to avoid a second post — check the account.
publishing_paused503Publishing is paused for this destination.Deferred and picked up when the pause lifts.
platform_unavailable503The platform is down or not answering.Retried with backoff.
platform_error502The 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.

A drawing of an agent with a read-only switch calling a panel of MCP tools, from list_platforms to publish_draft, which go through the relay to three platform tiles that each carry a tick.

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 true to remove the writing tools from the list entirely. Anything else leaves them in.
An MCP client’s server entryjson
{
  "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"
      }
    }
  }
}
ToolChanges anythingWhat it does
list_platformsreadsEvery platform, whether it can publish now, and what each accepts.
list_connectionsreadsThe organisation’s authorised accounts.
list_postsreadsRecent posts, newest first, with status and destinations.
get_postreadsOne post and every delivery under it.
get_post_metricsreadsWhat platforms report about a published post, per destination.
list_deliveriesreadsOne row per destination across all posts.
preview_postreadsExactly what each platform would receive, without publishing.
create_postwritesCreates a post — a draft, unless asked to publish or schedule.
publish_draftwritesSends a draft to its destinations now.
cancel_postwritesCancels 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.

One family

The rest of Rutba

One account across all of it. Sign in once and the products know each other.

RELAY-DEVELOPERS · 1f157db