Skip to main content

Syndication

Syndication hands a workspace's channels' posts on to its subscribers: whoever signs up 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 subscriber chooses the group's feeds they want and gives an email address and/or a webhook, as the group allows, and receives every post of those feeds when it is handed over to the workspace. Nothing comes back from a subscriber: they hold no key and report nothing. The API calls subscribers partners (/v1/syndication/partners), as the plan's allowance does.

Syndication is part of Enterprise. On any other plan every route below that changes something answers 409; reading and removing still work. How many subscribers a workspace may have is its syndication partners allowance, partners in GET /v1/syndication/settings, counting the ones who can receive something.

MethodPathPermission
GET/v1/syndication/settingssyndication:read
PUT/v1/syndication/settingssyndication:write
POST/v1/syndication/previewsyndication:write
POST/v1/syndication/sender/testsyndication:write
GET/v1/syndication/groupssyndication:read
GET/v1/syndication/groups/{id}syndication:read
POST/v1/syndication/groupssyndication:write
PATCH/v1/syndication/groups/{id}syndication:write
POST/v1/syndication/groups/{id}/deletesyndication:write
POST/v1/syndication/groups/{id}/partnerssyndication:write
GET/v1/syndication/partnerssyndication:read
GET/v1/syndication/partners/{id}syndication:read
PATCH/v1/syndication/partners/{id}syndication:write
POST/v1/syndication/partners/{id}/deletesyndication:write
GET/v1/syndication/deliveriessyndication:read
POST/v1/syndication/deliveries/{id}/redeliversyndication:write

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 feeds",
"description": "Our daily clips, ready to post: choose the feeds you want.",
"channelIds": ["01M1VFRWE7EWSY5D7QDQ0KNK1S", "01M1VFRWE7EWSY5D7QDQ0KNK1T"],
"byEmail": true,
"byWebhook": true,
"allowedOrigins": ["https://portal.example.com"]
}'
Field
nameThe workspace's own name for it, up to 80 characters. Never shown to subscribers.
descriptionOptional: what subscribers read at the top of its iframe, up to 300 characters.
channelIdsIts channels, each one of yours. The iframe offers them as feeds; with one there is nothing to choose, and every subscriber receives it.
byEmail, byWebhookHow its posts reach subscribers: 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.
allowedOriginsThe 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.
showHeaderWhether 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.
positionPATCH 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, portalKey, the key to sign customer IDs with (below), and partners, how many signed up 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 subscribers as a feed, one taken out is no longer handed on. POST .../delete removes it with its page and its subscribers. A page's address never changes: a group whose iframe went astray is replaced by a new group.

Each group also has one webhook signing secret, made with it. It is shown only in the group's iframe, to subscribers, beside the webhook field; no route returns it.

The page tells the frame around it how tall it is with a postMessage of { type: "partner-frame:height", height }.

Customer IDs​

A portal that knows who is signed in can name them to the page, which then shows that customer what they signed up with, to change or stop. Add two values to the iframe's address:

<iframe src="https://reelwire.io/embed/<code>?customer=C-1001&sig=<signature>" ...></iframe>

customer is the portal's own ID for them: 1 to 100 printable characters, no space at either end, URL-encoded in the address. sig is the HMAC-SHA256 of the ID, keyed with the group's portalKey, in hex. Make it on the portal's server:

import crypto from "node:crypto";
const sig = crypto.createHmac("sha256", process.env.REELWIRE_PORTAL_KEY).update(customerId).digest("hex");
$sig = hash_hmac("sha256", $customerId, getenv("REELWIRE_PORTAL_KEY"));

The key never goes to a browser. A page whose address names a customer without the right signature answers 403 and shows nothing to sign up with, and so does a sign-up or a test call carrying one: the ID decides whose address and webhook the page shows, so an unsigned one would let anybody read and replace another customer's by editing the address. A customer has one sign-up per group, and customerId shows it on the subscriber. A signed page's sign-up changes theirs rather than adding another: new feeds replace the old; an address typed again is asked to confirm, the confirmed one receiving until then; a new webhook is tested and replaces the old; an empty field stops that way.

Signing up​

In the iframe a subscriber chooses the feeds they want, where there are several, and gives an email address, a webhook address or both, as the group allows:

  • An email address gets an email asking its owner to confirm it. Nothing else is sent to it until they press it. The same address cannot sign up twice to one group.
  • A webhook is called with a test as it signs up, signed with the group's secret, which the iframe showed beside the field 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 offers another test until it does. The same webhook signed up again before it answered a test replaces the first.

There is no account and no settings page: an address stops by the link in every email, a webhook by answering any call with 410 Gone, and either can sign up again. A few sign-ups an hour are accepted from one internet address, and sign-ups that can reach nobody (never confirmed, or stopped) are removed after a week.

Subscribers​

GET /v1/syndication/partners answers a page of the subscribers, newest first, with total and room (subscribers who can receive something, and the most allowed). It takes q (an address or a webhook), groupId, page, perPage (up to 500) and ascending. The dashboard shows the same list per group, behind Subscribers on the group's row.

{
"partner": {
"id": "01M3QZ2F6T0Y4G3X9J8K7N5B2C",
"label": "C-1001",
"customerId": "C-1001",
"groupId": "01M3M6X0CB978K4MC4PC59706X",
"group": { "id": "01M3M6X0CB978K4MC4PC59706X", "name": "All feeds" },
"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" },
"reachable": true,
"lastDelivery": null,
"createdAt": "2026-09-28T10:02:11.000Z",
"updatedAt": "2026-09-28T10:04:40.000Z"
}
}

label is how a subscriber is known: the portal's customerId where it named them, else their address, else their webhook's host. q searches customer IDs, addresses and webhooks. channelIds and feeds are what they receive of their group now. source is self for a sign-up in the iframe and owner for one the workspace added; blocked says whether the workspace blocked them. PATCH /v1/syndication/partners/{id} changes only the fields it is given:

FieldWhat it does
pausedNothing is sent while paused, and nothing waits for later.
blockedNothing is sent, the same address, webhook or customer ID cannot sign up in the group again, and they do not count against the allowance. false unblocks.
customerIdThe workspace's own ID for them, one per group; null clears it.
channelIdsTheir feeds, of the group's channels, at least one where the group has several.
emailAnother 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.
webhookUrlAnother 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).

An address or webhook another subscriber of the group has, or a blocked one, is refused with 409, and so is another's customer ID. POST /v1/syndication/partners/{id}/delete removes a subscriber for good, with their lines in the log; they can sign up again unless blocked.

POST /v1/syndication/groups/{id}/partners adds a subscriber on the workspace's behalf, with the same fields as a sign-up: channelIds (where the group has several), email and/or webhookUrl as the group allows, and an optional 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), 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 customer ID already in the group is refused with 409, and so is a blocked one.

What a subscriber receives​

Every hand-over of a channel a subscriber takes is passed on to them, by the ways their group allows, whether the channel hands over to you by email, by webhook or both. A channel that asks for approval passes a post on once it is approved. An update or a delete goes only to the subscribers who were given that post; a resend of a post to your own people is not passed on. A paused subscriber is sent nothing. If a workspace has more subscribers than its allowance, the oldest are served first.

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 subscriber's post kit. The kit has the video to download or share, its cover, the text with a copy button and a prompt for an AI assistant, 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.

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:

  • channels holds only the feeds the subscriber takes;
  • deliveryId is the subscriber's own, and attempt counts their retries;
  • syndication says it was passed on: { "partnerId": "...", "from": "Acme Markets" };
  • 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 subscriber cannot report against it. The test call is a ping with the same syndication.

A call that is not answered with a 2xx is tried again after 10 seconds, a minute, 5 minutes, 15 minutes, an hour and two hours, then gives up. A call answered with 410 Gone takes the webhook off.

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
titleBeside the logo at the top, up to 80 characters. Default: the workspace's name.
footerUnder the card, up to 600 characters. Line breaks are kept.
logomedia://<id> of a PNG, JPEG or GIF in the media library, at most 512 KB.
background, card, accent, signatureColours as #RRGGBB: the page, the card, buttons and links, the title and headings.
senderNameThe From line's name. Default: the title.
senderModeHow 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.
senderLocalWith reelwire: the part before the @ of the sending address, on Reelwire's sending domain: lower case letters, digits, dots and hyphens. No mailbox stands behind it. Default: Reelwire's no-reply address.
smtpHostWith 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.
smtpPortWith smtp: 587, 465, 25 or 2525, the ports mail is sent on.
smtpSecurityWith smtp: starttls (usual with 587), tls (465) or none.
smtpUser, smtpPasswordWith smtp: the mail account's login. The password is kept encrypted; an empty or absent one keeps the stored one.
smtpFromWith smtp: the address the emails come from, one the account may send as.
replyToWhere a subscriber's reply goes.

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 subscriber sees, in the saved branding with anything in draft laid over it:

kind
post (the default)The email that a post is ready: its subject, from and html.
confirmThe email that asks a new address to confirm.
kitThe 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.
pageA group's sign-up page, as html, with its Sign up button switched off and a stand-in where the signing secret goes.

groupId names the group whose feeds and ways it shows. group (description, channelIds, byEmail, byWebhook) lays unsaved values over that group's, or stands in for a group not made yet, which is how the dashboard shows all three before Add group.

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", "error": null }, with the mail server's own words in error where it did not go. Ten a minute.

The log​

The dashboard shows no log; the API does. GET /v1/syndication/deliveries lists every email (EMAIL) and webhook call (WEBHOOK) to a subscriber, newest first: partnerLabel (their address or webhook host), event, channels, state (PENDING, DELIVERING, DELIVERED, RETRYING, DEAD, or SKIPPED where the way was gone by the time it was due), where it went (target) and what came back. It takes partnerId, state, mechanism, event, q, page and perPage.

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.