Syndication
Syndication hands a workspace's channels' posts on to its participants: whoever subscribes in one
of its groups' iframes. The workspace makes groups of its channels, each
with an iframe of its own that it puts in its own portal. In it a participant chooses the group's
channels they want and subscribes an email address and/or a webhook, as the group allows, each
confirmed by email, and receives every post of those channels when it is handed over to the workspace. Nothing comes back from a participant: they
hold no key and report nothing. The API calls participants partners (/v1/syndication/partners),
as the plan's allowance does.
Syndication is part of Enterprise. On any other plan every route below that changes, sends or draws
something answers 409, previews and test emails included; reading still works, and so do removing a
group and removing a participant, so a workspace that moved down a tier can still clear what it had.
How many participants a workspace may have is its
syndication participants allowance, partners in GET /v1/syndication/settings, counting the ones who can
receive something.
| Method | Path | Permission |
|---|---|---|
GET | /v1/syndication/settings | syndication:read |
PUT | /v1/syndication/settings | syndication:write |
POST | /v1/syndication/preview | syndication:read |
POST | /v1/syndication/sender/test | syndication:write |
GET | /v1/syndication/groups | syndication:read |
GET | /v1/syndication/groups/{id} | syndication:read |
POST | /v1/syndication/groups | syndication:write |
PATCH | /v1/syndication/groups/{id} | syndication:write |
POST | /v1/syndication/groups/{id}/delete | syndication:write |
POST | /v1/syndication/groups/{id}/partners | syndication:write |
GET | /v1/syndication/partners | syndication:read |
GET | /v1/syndication/partners/{id} | syndication:read |
PATCH | /v1/syndication/partners/{id} | syndication:write |
POST | /v1/syndication/partners/{id}/delete | syndication:write |
GET | /v1/syndication/deliveries | syndication:read |
POST | /v1/syndication/deliveries/{id}/redeliver | syndication:write |
The preview is a POST only because what it draws is sent as a document; it changes nothing, so a
key that may only read syndication may ask for one.
Groups
curl -X POST http://localhost:4000/v1/syndication/groups \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "All channels",
"description": "Our daily clips, ready to post: choose the channels you want.",
"channelIds": ["01M1VFRWE7EWSY5D7QDQ0KNK1S", "01M1VFRWE7EWSY5D7QDQ0KNK1T"],
"byEmail": true,
"byWebhook": true,
"allowedOrigins": ["https://portal.example.com"]
}'
| Field | What it is |
|---|---|
name | The workspace's own name for it, up to 80 characters. Never shown to participants. |
pageTitle | Optional: the heading of its iframe, up to 100 characters; null or left out reads "Ready-to-post videos from" and the branding's title. |
description | Optional: what participants read at the top of its iframe, up to 300 characters. |
channelIds | Its channels, each one of yours and not removed, at most 500. The iframe offers them to choose from; with one there is nothing to choose, and every participant receives it. An id that is not one of your channels is a 404 naming it. |
byEmail, byWebhook | How its posts reach participants: an email with the post kit, a call to their webhook, or both. At least one; both by default. The group decides, whatever its channels hand over by. |
allowedOrigins | The sites whose pages may show it, as origins such as https://portal.example.com (a path is dropped), at most 20. Sent as the page's Content-Security-Policy: frame-ancestors, and a page naming sites does not open on its own. Empty or left out is any site. |
showHeader | Whether the page shows the logo and title above its cards. true by default; false for a portal that frames it under a header of its own. |
position | PATCH only: where the group stands in the list, from 0. |
Every group comes back with page.url, its page's address, page.frame, the iframe code to copy into
the portal's HTML (it carries allow="clipboard-write", so the page's Copy buttons work inside the
frame), portalKey, the key to sign participant IDs and email addresses with (below), channels,
each of its channels with its name, surface, ratio and language, position, and partners, how many
subscribed in it. GET /v1/syndication/groups answers them
all in their order, and GET /v1/syndication/groups/{id} one of them. PATCH changes any of the fields above; a channel added is offered to its
participants as a new choice, one taken out is no longer handed on. POST .../delete removes it with its page
and its participants. A page's address never changes: a group whose iframe went astray is replaced by a
new group. While the workspace is not on Enterprise, every group's page answers 404.
What reaches participants never depends on a channel's own ways out (deliverByEmail,
deliverByWebhook, deliverByConnector): a channel with all of them off still hands its posts on to
every participant whose group takes it. A participant's webhook is given five seconds to answer, and
one that does not is tried again on the retry ladder, so a slow receiver never holds up the others. GET /v1/syndication/deliveries is the
log of every email and call, and takes groupId beside its other filters; each line names its
group.
One number bounds both groups and participants: the plan's syndication participants allowance. A
workspace may hold that many groups, and that many participants across all of its groups together
(only those who can receive something count), so one group may hold them all, or every group one.
One group more is refused with 409 and a sentence naming the number.
Each group also has one webhook signing secret, made with it. It is shown in the group's iframe, to participants, beside the webhook field. The one route that answers it is adding a participant with a webhook (below), once, so the workspace can pass it on; no other route returns it.
The page tells the frame around it how tall it is with a postMessage of
{ type: "partner-frame:height", height }.
Participant IDs and email addresses
A portal that knows who is signed in can name them to the page, which then shows them what they subscribed with. Add two values to the iframe's address for the portal's own ID for them, two for their email address, or all four:
<iframe src="https://reelwire.io/embed/<code>?participant=C-1001&sig_participant=<signature>&email=anna%40smith.test&sig_email=<signature>" ...></iframe>
participant is the portal's own ID for them, their participant ID: 1 to 100 printable characters,
no space at either end, URL-encoded in the address. sig_participant is the HMAC-SHA256 of the ID,
keyed with the group's portalKey, in hex. email is their address and sig_email its signature,
made the same way, of the address as passed or in lower case. With the address the page asks for
none: confirmations go to it, and it finds their subscription by it. Make each signature on the
portal's server:
import crypto from "node:crypto";
const key = process.env.REELWIRE_PORTAL_KEY;
const sig_participant = crypto.createHmac("sha256", key).update(participantId).digest("hex");
const sig_email = crypto.createHmac("sha256", key).update(email).digest("hex");
$key = getenv("REELWIRE_PORTAL_KEY");
$sig_participant = hash_hmac("sha256", $participantId, $key);
$sig_email = hash_hmac("sha256", $email, $key);
The key never goes to a browser. A page whose address names a participant or an address without the
right signature answers 403 and shows nothing to subscribe with, and so does any of the page's calls
carrying one: the ID and the address decide whose subscription the page shows, so an unsigned one would
let anybody read and change another's by editing the address. A participant has one subscription per
group, and customerId shows the ID on the participant. The first names of these values, customer,
sig and esig, are still taken where the new ones are absent.
Subscribing and unsubscribing
In the iframe a participant chooses the channels they want, where there are several, and subscribes an
email address, a webhook or both, as the group allows, each in its own card. Every subscription,
change of channels and unsubscription is confirmed by email, in two steps: the email's link opens a
confirmation page, and only the button on that page acts (GET asks, POST does it), so a mail
scanner opening links changes nothing:
- An email address gets an email asking to confirm the subscription (
/v1/partner/confirm/...). Nothing else is sent to it until the page's button is pressed. The same address cannot subscribe twice to one group. - A webhook is given with an email address to confirm it from, which receives no posts; it is not
asked where the portal passed an address or the participant gave one before, and it is kept as
contactEmail. The confirmation (/v1/partner/webhook/...) makes it the participant's webhook and calls it with a test, signed with the group's secret, which the iframe shows with example code for Node.js, Python, PHP and C#. No post goes to it until a test call has answered with a 2xx. The iframe always offers a test, before subscribing as well as after: it tries the address shown or typed, and only an answer from the participant's own webhook counts. A test of an address not theirs keeps nothing, and its ping carries nosyndicationnote. - Join / Update, for somebody the portal did not name: an address or webhook that receives
already gets an email asking to confirm the channels chosen now (
/v1/partner/channels/...), and they change as it is confirmed. The channels a confirmation is for travel in its link, so somebody who receives something keeps their channels until then. A participant the portal named changes them at once. - Leave emails the confirmation: for the emails, to the address; for a webhook, to the
address it was confirmed from; in use or still waiting alike (
/v1/partner/stop/...,/v1/partner/webhook-stop/...). The stop link in every post email is the same page, and the one-clickList-Unsubscribe-Posta mail app sends acts at once. A participant left with no way at all is deleted, with their log lines, rather than kept as unconfirmed.
A confirmation link works for three days; the stop link in a post email never expires, since a way out
that expires is no way out. For somebody the portal did not name, every answer is the same whether the
address or webhook is subscribed or not (neutral), so the page never says who is. There is no
account and no settings page: a webhook also stops by answering a post's call with 410 Gone, and
either way can subscribe again.
Each button in the iframe is a call the page makes to its own address, POST /embed/<code>/
followed by subscribe-email, subscribe-webhook, unsubscribe-email, unsubscribe-webhook or
channels: they belong to the page, not to the API, and take ten calls an hour from one internet
address. Send a test call is POST /embed/<code>/test, twenty a minute. At most five confirmation
emails a day go to one participant, never two within a minute. A page that names its visitor asks
POST /embed/<code>/state every five seconds whether anything changed, so a confirmation pressed in
an email shows there without a reload. Participants who never confirm anything are removed after a
week.
Participants
GET /v1/syndication/partners answers a page of the participants, newest first, with total,
page, perPage and room (used, the participants who can receive something, and max, the most
allowed). It takes q (a participant ID, an address or a webhook), groupId, page, perPage (50
when left out, up to 500) and ascending. The dashboard shows the same list per group, behind
Participants on the group's row. GET /v1/syndication/partners/{id} answers one, drawn as every
entry of the list is:
{
"partner": {
"id": "01M3QZ2F6T0Y4G3X9J8K7N5B2C",
"label": "C-1001",
"customerId": "C-1001",
"source": "self",
"blocked": false,
"blockedAt": null,
"groupId": "01M3M6X0CB978K4MC4PC59706X",
"group": { "id": "01M3M6X0CB978K4MC4PC59706X", "name": "All channels" },
"channelIds": ["01M1VFRWE7EWSY5D7QDQ0KNK1S"],
"feeds": [{ "id": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "name": "Daily news" }],
"paused": false,
"email": "anna@smith-invest.com",
"emailConfirmedAt": "2026-09-28T10:04:40.000Z",
"pendingEmail": null,
"pendingEmailAt": null,
"webhook": { "url": "https://smith-invest.com/hooks/posts", "since": "2026-09-28T10:02:11.000Z", "verifiedAt": "2026-09-28T10:03:02.000Z" },
"pendingWebhook": null,
"contactEmail": "anna@smith-invest.com",
"reachable": true,
"lastDelivery": null,
"createdAt": "2026-09-28T10:02:11.000Z",
"updatedAt": "2026-09-28T10:04:40.000Z"
}
}
label is how a participant is known: their participant ID (customerId) where the portal named
them, else their address, else their webhook's host, with "(not confirmed)" after one still waiting.
pendingWebhook (url, at) is a webhook given in the iframe and waiting for its owner to confirm
it by email, and contactEmail is where their confirmations go. channelIds and feeds are the
channels they receive of their group now. source is self for a subscribe in the iframe and owner
for one the workspace added; blocked says whether the workspace blocked them, and since when.
reachable says whether anything can reach them now, and lastDelivery is the newest line of the log
for them (state, mechanism, event, at, error).
PATCH /v1/syndication/partners/{id} changes only the fields it is given:
| Field | What it does |
|---|---|
paused | Nothing is sent while paused, and nothing waits for later. |
blocked | Nothing is sent, the same address, webhook or participant ID cannot subscribe in the group again, and they do not count against the allowance. false unblocks. |
customerId | Their participant ID, the workspace's own, one per group; null clears it. |
channelIds | Their channels, of the group's, at least one where the group has several. |
email | Another address gets an email asking its owner to confirm it, and the answer says so in pendingEmail; the confirmed address keeps receiving until then. null stops the emails. |
webhookUrl | Another webhook is called with a test at once, the answer carrying test, and gets posts once it answers with a 2xx. null removes it (so does webhook: null). |
resendInvite | true sends the email asking them to confirm their address again, to the address still waiting for confirmation, and the answer names it in pendingEmail. At most one a minute and five a day per participant; a 409 where no address waits or the participant is blocked. |
An address or webhook another participant of the group has, or a blocked one, is refused with 409,
and so is another's participant ID. Where the confirmation email could not go just now, the answer
carries letterProblem with the reason, and the rest of the change is kept all the same.
POST /v1/syndication/partners/{id}/delete removes a participant for good, with their lines in the
log, and they can subscribe again. Removing a blocked participant ends the block with them, so block
somebody who should stay out rather than remove them.
POST /v1/syndication/groups/{id}/partners adds a participant on the workspace's behalf, with the same
fields as the iframe: channelIds (where the group has several), email and/or webhookUrl as the
group allows, and an optional participant ID, customerId. It answers 201 with the partner (source owner),
pendingEmail where a confirmation email went out (an address is confirmed by its owner, whoever
added it), letterProblem where it could not go, test where a webhook was called, and, with a
webhook, webhookSecret: the group's signing secret, once, for the workspace to pass on. The same
address, webhook or participant ID already in the group is refused with 409, and so is a blocked
one, and so is anybody new once the allowance is reached.
What a participant receives
Every hand-over of a channel a participant takes is passed on to them, by the ways their group allows, whichever ways the channel hands over to you by, or none. A channel that asks for approval passes a post on once it is approved. An update or a delete goes only to the participants who were given that post; a resend of a post to your own people is not passed on. A paused participant is sent nothing. If a workspace has more participants than its allowance, the oldest are served first.
To pass a post on again, POST /v1/jobs/{id}/resend-participants (posts:write) sends it, as it
reads now, to every participant whose group takes the channel; a 409 where no group with somebody
subscribed takes it. See Sending a post again.
By email
An email in your branding: "Your video is ready to post", "This post changed", "Please take this post
down", with a button to the participant's post kit. The kit has the video to download or share, its
cover and the text with a copy button, and nothing to report with. Its
link works for 14 days. Every email ends on a link to stop them, which is also its List-Unsubscribe
header. Every email a participant gets, the confirmations included, names the broadcast in a box headed
Broadcast under its first line (participants join a broadcast and leave it: no page or email speaks of a
subscription): the group's pageTitle (or "Ready-to-post videos from" and the branding's title)
and the channels it is about, each with its platform, shape and language; the email asking to confirm joining
also carries the group's description.
By webhook
The same document as your own webhook call (Webhooks), with the same headers, signed the same way with the group's secret, and:
channelsholds only the channels the participant takes;deliveryIdis the participant's own, andattemptcounts their retries;syndicationsays it was passed on:{ "partnerId": "...", "from": "Acme Markets" };- on a publish,
omittednames only channels they take; - who approved a post (
approvedBy,approvedAt), the post list or the stream it came from, a manual post's title, and the channels they do not take are left out.
postId identifies a post across a later post.update or post.delete; a participant cannot report
against it. The test call is a ping with the same syndication.
A call that is not answered with a 2xx within five seconds is tried again after 10 seconds, a minute,
5 minutes, 15 minutes, an hour and two hours, then gives up; an email the mail server refuses climbs
the same ladder. A call answered with 410 Gone takes the webhook off, and its line reads SKIPPED;
a participant it leaves with no way at all is then removed by the hourly sweep, with their lines.
Branding and email settings
GET /v1/syndication/settings answers what is saved (look and sender, null where the default
stands), what that comes to (effective), the defaults, the sending domain and the allowance.
sender also says how the emails go:
{
"sender": {
"name": null,
"local": null,
"replyTo": "desk@acme-markets.com",
"mode": "smtp",
"smtp": {
"host": "mail.acme-markets.com",
"port": 587,
"security": "starttls",
"user": "no-reply@acme-markets.com",
"from": "no-reply@acme-markets.com",
"password": true
}
}
}
password only says whether one is kept: no route answers the password itself.
PUT /v1/syndication/settings saves any of these; null puts one back to its default:
| Field | What it sets |
|---|---|
title | Beside the logo at the top, up to 80 characters. Default: the workspace's name. |
footer | Under the card, up to 600 characters. Line breaks are kept. |
logo | media://<id> of a PNG, JPEG or GIF in the media library, at most 512 KB. |
background, card, accent, signature | Colours as #RRGGBB: the page, the card, buttons and links, the title and headings. |
font | The font of every group's page and of the pages the emails open, { font, size, file }: font one of the preinstalled fonts a brand can use (such as inter; open-sans where left out), size from 0.8 to 1.4 (default 1), and file a font file from the media library (media://<id>, WOFF2, WOFF, TTF or OTF) drawn instead. null is the usual one. It draws the pages and the emails; a mail app that loads no web fonts shows the nearest one it has. |
senderName | The From line's name, up to 60 characters, without ", <, >, @ or \. Default: the title. |
senderMode | How the emails go: reelwire, from an address on Reelwire's sending domain through Reelwire's mail service (the default), or smtp, through the workspace's own mail server. One or the other. |
senderLocal | With reelwire: the part before the @ of the sending address, on Reelwire's sending domain: 2 to 40 lower case letters, digits, dots and hyphens, starting and ending on a letter or digit. No mailbox stands behind it. Reelwire's own addresses (support, billing, no-reply and the like) and anything with "reelwire" in it are refused, and one another workspace sends from is a 409. Default: Reelwire's no-reply address. |
smtpHost | With smtp: the mail server, such as mail.example.com. It must be on the internet: a name or address inside a private network is refused. |
smtpPort | With smtp: 587, 465, 25 or 2525, the ports mail is sent on. |
smtpSecurity | With smtp: starttls (usual with 587), tls (465) or none. |
smtpUser, smtpPassword | With smtp: the mail account's login. The password is kept encrypted; an empty or absent one keeps the stored one. |
smtpFrom | With smtp: the address the emails come from, one the account may send as. |
replyTo | Where a participant's reply goes: an address that can receive mail. |
With smtp, every one of its fields must be set, and the server is tried before anything about it is
saved: one that does not answer or does not take the login is a 400 quoting the server, and nothing
is saved.
Every other colour follows from the four: words are dark or light by what they sit on. The same
branding is worn by every group's iframe, every email and the post kit, and none of them carries
Reelwire's name, except where neither logo nor title is set: then Reelwire's logo and name stand
at the top (effective.house is true), and the texts use the workspace's name.
POST /v1/syndication/preview draws what a participant sees, in the saved branding with anything in
draft laid over it:
kind | What it draws |
|---|---|
post (the default) | The email that a post is ready: its subject, from, replyTo, html and text. |
confirm | The email that asks a new address to confirm, in the same fields. |
kit | The post kit an email opens, as html: built from the newest post of the group's channels where there is one (sample: false), else from a sample. |
page | A group's sign-up page, as html, with its buttons switched off and a stand-in where the signing secret goes. |
groupId names the group whose channels and ways it shows. group (pageTitle, description,
channelIds, byEmail, byWebhook, showHeader) lays unsaved values over that group's, or stands in
for a group not made yet, which is how the group dialogue's What participants see shows the page,
an email and the post kit before anything is saved.
POST /v1/syndication/sender/test sends one test email in the branding, the way the saved settings
say with anything in draft laid over them (the same fields as PUT), so a mail server can be tried
before it is saved. It goes to the person signed in, or with an API key to the workspace's owner, and
to nobody else. It answers { "ok": true, "to": "...", "via": "smtp", "state": "sent", "error": null }:
state is sent, skipped where that address cannot receive mail, or failed, with the mail
server's own words in error. Ten a minute.
The log
GET /v1/syndication/deliveries lists every email (EMAIL) and webhook call (WEBHOOK) to a
participant, newest first, as the Syndication log card on the Syndication screen shows it:
partnerLabel (their participant ID, address or webhook host), group, event, channels, state
(PENDING, DELIVERING, DELIVERED, RETRYING, DEAD, or SKIPPED where the way was gone by the
time it was due), attempt, where it went (target) and what came back (lastStatusCode,
lastError). It takes partnerId, groupId, state, mechanism, event, q, ascending, page
and perPage (50 when left out, up to 200), and answers total, all (every line, unfiltered) and
facets, the values each filter can take.
POST /v1/syndication/deliveries/{id}/redeliver sends a line again from its first attempt: an email
with a fresh post kit link, a call with its clip links signed afresh. A line still on its way is a
409.
Through an AI assistant
Everything on this page is a tool of the syndication set, listed at /mcp/all and found from /mcp
with reelwire_find_tools: reelwire_list_syndication_groups, reelwire_add_syndication_group,
reelwire_update_syndication_group and reelwire_remove_syndication_group for the groups;
reelwire_list_partners, reelwire_add_subscriber (adding a participant), reelwire_update_partner
and reelwire_remove_partner for the participants; reelwire_syndication_settings,
reelwire_set_syndication_look, reelwire_syndication_preview and reelwire_syndication_test_email
for the branding and the emails; reelwire_list_partner_deliveries and reelwire_resend_to_partner
for the log. See Every tool.