Skip to main content

Streams and feeds

A stream is an address your own systems push data to. It carries one template and, where your document does not already fit that template, a field map that says where each of the template's inputs lives in your document. Channels subscribe to the stream and inherit both, so there is exactly one place the mapping is written and exactly one place it is checked.

Feeds are the other half: the data Reelwire ships, which a workspace's channels listen to rather than the workspace creating it. These routes configure both. Sending to a stream is Sending events. On the dashboard, streams are on Custom feeds and feeds on Ready-made feeds.

MethodPathPermission
GET/v1/streamsstreams:read
POST/v1/streamsstreams:write
POST/v1/streams/{id}/updatestreams:write
POST/v1/streams/{id}/deletestreams:write
POST/v1/streams/{id}/suggest-mapstreams:write
POST/v1/streams/{id}/testsigned-in person
GET/v1/feedsfeeds:read
PUT/v1/feedsfeeds:write
GET/v1/feeds/{feedId}/referencefeeds:read
GET/v1/feeds/{feedId}/reference/{ratio}feeds:read
GET/v1/feeds/{feedId}/reference/{ratio}/stillfeeds:read

Streams and feeds are two permission areas, Custom feeds (streams) and Ready-made feeds (feeds), because they are two different things: a stream is your own pipe, a feed is something Reelwire carries. /v1/streams and /v1/stream are different routes in different areas too. The plural one configures streams and needs streams; the singular one takes events and needs ingest:write.

Create a stream​

curl http://localhost:4000/v1/streams \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Docs examples stream",
"description": "Created while writing the reference",
"templateId": "tpl_dd7e6b264fb8",
"fieldMap": {
"/clipData/label": { "from": "/article/title", "as": "string", "maxLength": 70 },
"/clipData/value": { "from": "/article/figure", "as": "number" },
"/clipData/source": { "const": "Orchid Invest" }
}
}'
{
"stream": {
"id": "st_d84d89e39bfc",
"name": "Docs examples stream",
"description": "Created while writing the reference",
"enabled": true,
"templateId": "tpl_dd7e6b264fb8",
"fieldMap": {
"/clipData/label": { "as": "string", "from": "/article/title", "maxLength": 70 },
"/clipData/value": { "as": "number", "from": "/article/figure" },
"/clipData/source": { "const": "Orchid Invest" }
},
"subscribers": 0,
"listeners": []
}
}

201. The body:

FieldRequiredWhat it is
idnost_ and twelve hex characters, where you want to choose it. Omit it and Reelwire draws one. An id already taken in any workspace is a 409.
nameyes1 to 120 characters.
descriptionnoUp to 500 characters.
enablednoDefaults to true.
templateIdyesThe template every event on this stream is drawn with, one this workspace is offered. Any other is a 404.
fieldMapnoDefaults to {}, which is pass-through.

A templateVariant or templateVersion sent here is dropped: which cut and which build a clip is drawn with is the choice of each channel that subscribes.

The template has to have a build to render with, or the stream is refused with a 404 that says so. A workspace holds up to 50 streams unless Reelwire has set it otherwise; past that, a 409 names the number. A workspace whose subscription was cancelled can make no new stream (a 409) until it subscribes again, on any plan, Free included.

The field map​

A map is flat: each key is a JSON Pointer into the template's input document, and each value says where that input comes from in your document.

An RSS item's title and summary sent to a stream, whose field map places them in the template's headline and subline, with a constant for the source line

KeyWhat it does
fromA JSON Pointer into the posted data.
constA fixed value, for an input your data has no equivalent for.
fallbackUsed when from resolves to nothing. Absent is not the same as empty.
asCoercion before validation: string, number, integer, boolean or iso-date.
maxLengthCut over-long text to the template's limit rather than failing validation.

Give each entry a from or a const. Where from finds nothing, fallback is used, then const if the entry has both; with neither a fallback nor a const, the event is unmapped and its mappingErrors name the field (see the statuses). Keys must start with /, and their first step must be one of the template's own inputs (/clipData/... or /metaData/... for every template in the catalogue): a map aimed anywhere else is a 400 that lists what the template accepts, when the stream is made or changed.

An empty map means pass-through, not "drop everything", and it is now the ordinary case: a template publishes the shape it accepts at /v1/formats/template/{templateId}, so a producer written against that already emits the right document and has nothing to map.

You can see a map run without sending anything, on /v1/stream/validate. The raw document goes in and document comes back as the template will see it:

curl http://localhost:4000/v1/stream/validate \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"streamId": "st_d84d89e39bfc",
"eventId": "01M2SQMKK3G1NVFXV31DCBVB95",
"occurredAt": "2026-09-18T08:00:00Z",
"data": {
"article": { "title": "Assets under management at Orchid Invest", "figure": "12.4", "body": "..." },
"publishedBy": "newsroom"
}
}'
{
"requestId": "01M2SQYACSHKW9FHFD8RYAK1WT",
"streamId": "st_d84d89e39bfc",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": "1.0.0",
"valid": true,
"document": {
"clipData": {
"label": "Assets under management at Orchid Invest",
"value": 12.4,
"source": "Orchid Invest"
}
},
"warnings": [{ "path": "/clipData/value", "message": "converted to number" }],
"errors": [],
"wouldMatch": []
}

wouldMatch is empty because no channel subscribes to this stream yet. The document is valid all the same. Each coercion that changed a value, each fallback used and each text cut to maxLength is a warning; a pointer that finds nothing, with no fallback or const, or a value that cannot be coerced, is an error.

Each channel is checked against the variant it draws. A template's variants may take different content: in a package, each variant is a different post with its own fields, and a field one variant requires another may not have. So the document is checked once for every channel listening to the stream, against that channel's own variant and version, and wouldMatch lists only the channels that would make a post. A channel whose variant refuses the document is left out and named in warnings ("Not posted on ..."), while the others still post; when no channel's variant takes it, valid is false and errors say why, naming the channels and the variant where they differ. With no channel listening, the document is checked against the template's first variant. A real event on /v1/stream is judged the same way: accepted for the channels it fits, with the others named under warnings, and unmapped, with mappingErrors, only when it fits none.

Let Reelwire suggest a map​

POST /v1/streams/{id}/suggest-map matches your own field names against the template's inputs and proposes a map. It needs one real payload to work from, and says so plainly when there is none, with a 409:

curl http://localhost:4000/v1/streams/st_d84d89e39bfc/suggest-map \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{}'
{
"error": {
"type": "https://reelwire.io/errors/no_sample",
"code": "no_sample",
"message": "This stream has not received anything yet",
"requestId": "01M2SQYKXH7B2JW3PWQ24YHV3Q",
"details": [
{
"path": "",
"message": "Send one event from your script first. The suggestion is built from your own field names, so there is nothing to match against until then."
}
]
}
}

Send one event, then ask again. A stream keeps the most recent payload it received for exactly this. Once it has one, the answer is suggestion (a map to review, never applied by itself), templateFields (every input the template has) and sample (the payload it matched against). Send templateId, and templateVersion if you like, to see a map for another template before switching.

List streams​

curl http://localhost:4000/v1/streams \
-H "Authorization: Bearer rw_your_key_here"
{
"route": "http://localhost:4000/v1/stream",
"streams": [
{
"id": "st_30472168d941",
"name": "Enterprise 2 Stream 1",
"enabled": true,
"templateId": "tpl_dd7e6b264fb8",
"fieldMap": {},
"lastReceivedAt": "2026-09-18T07:45:50.088Z",
"subscribers": 2,
"listeners": [
{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de" },
{ "channelId": "01M2985NXJRXVE5HKCCGKASP8P", "channelName": "Channel 10 en" }
]
}
]
}

subscribers is how many channels consume the stream. Zero means nothing renders, whatever you send. Each stream also carries lastSample, the most recent payload it received, once it has one.

Change or remove a stream​

POST /v1/streams/{id}/update takes the same fields as create, all of them optional, except id: the id is deliberately not editable, because your script names it in every request and changing it would look from your side like Reelwire returning 404 for no reason. Changing templateId or fieldMap is checked against the template's newest build before it is saved, and against the stream's last payload where it has one, so a map that saves is a map that renders. It answers { "ok": true }. A new template sends every subscribing channel back to the template's first variant and its newest build, because what they had chosen belonged to the old template.

POST /v1/streams/{id}/delete removes the stream and answers { "ok": true, "stopped": 2 }: from then on events sent to it are refused, and the channels that listened stop, which is what stopped counts. What it had already received still renders and posts. The stream's history stays, under its name with " (removed)" on the end, so check listeners before you call it.

Test a stream from the dashboard​

POST /v1/streams/{id}/test is the route behind the Test tool on Custom feeds, and it is a signed-in person's alone: every API key is refused there, whatever it holds, and the person needs Operations, as making a stream does. It takes the event's data and builds the rest of the envelope the way your producer would: the stream from the path, a fresh eventId, and now as occurredAt.

FieldRequiredWhat it is
datayesAn object: what a producer would send as data.
checknotrue runs the dry run and stores nothing. Left out, the event is sent for real.

With check, the answer is { "check": ... }, holding exactly what POST /v1/stream/validate answers. Without it, the answer is a 202 { "sent": ... }, holding what POST /v1/stream answers, and every channel listening to the stream renders and hands over a post of it like any other. A paused or unknown stream is refused as it is there.

It is a person's because it sends real events: a key does that only with Custom feeds - Send raw data (ingest:write), on POST /v1/stream, and a person testing a stream should not have to make a key first. See Sending events.

Feeds​

The ready-made feeds carry live data: Reelwire's own feed server fetches it from public sources, builds the content each feed's template takes, and sends every event to each workspace with a channel listening. Nothing about them needs a key or a producer of yours.

GET /v1/feeds lists every shipped feed the account is offered, grouped by source, with the template each is drawn through, the channels listening to it (listeners, counted in subscribers), when it is expected to post, and languages, the languages its content comes in, en always among them: a channel in another language gets its English content. It is what the Ready-made feeds screen shows.

What a workspace is offered follows the industries its owner chose (see Your industries): a feed graded core or possible for one of them. Nothing else unlocks one. That decides what a channel may subscribe to, and nothing after: there is no market check on the events themselves, so a feed the account is no longer offered stays listed, and keeps posting, while a channel listens to it.

curl http://localhost:4000/v1/feeds \
-H "Authorization: Bearer rw_your_key_here"
{
"sources": [
{
"slug": "crypto-spot-and-dominance",
"title": "Crypto spot and dominance",
"category": "Market and financial data",
"description": "Prices, market values and 24-hour moves of the largest coins, and Bitcoin's share of the whole crypto market.",
"cadence": "Daily, and price milestones as they happen",
"period": "Daily",
"implementation": "Feed server: crypto market data",
"milestone": 1,
"entitled": false,
"live": true,
"formats": [
{
"id": "fd_c16f5268064a",
"slug": "crypto-board",
"name": "Crypto board",
"content": null,
"title": "Crypto board",
"example": "The eight largest coins with their 24-hour moves",
"description": "The eight largest coins by market value, excluding stablecoins, with their price and 24-hour move. Every morning.",
"group": "Crypto spot and dominance",
"contentStyle": null,
"milestone": 1,
"templateId": "tpl_06dc2554e5ac",
"templateName": "Number grid",
"templateVariant": "Data terminal",
"maxAgeSeconds": null,
"live": true,
"entitled": false,
"offered": true,
"enabled": true,
"subscribers": 1,
"listeners": [{ "channelId": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "channelName": "Channel 1 en" }],
"schedule": { "every": "daily", "time": "08:00" },
"scheduleLabel": "Daily 08:00",
"scheduleNote": null,
"sentLast30Days": 30,
"multilingual": false,
"languages": ["en"],
"type": "live",
"plans": ["FREE", "INDIVIDUAL", "SMALL_BUSINESS", "ENTERPRISE"],
"usable": true
}
]
}
]
}

id is the feed id, fd_ and twelve hex characters. Address a feed by its id wherever one is asked for: the slugs are words and words get edited, and a producer pinned to a title breaks the day somebody fixes its capitalisation. A feed Reelwire added itself is listed under the source slug custom, with its own id as the format slug.

offered is the field to read. entitled is left from when feeds were granted per plan and decides nothing any more. enabled is the workspace's own switch, below. templateId, templateName and templateVariant are the template and style the feed is drawn with, which a channel subscribing to it starts from. schedule and scheduleLabel say when the feed is expected to post, as the Posting calendar draws it, and scheduleNote says it in words where there is no rule. maxAgeSeconds is null here; an event older than a day is kept but never published (the catalogue's maxAgeSeconds, see Templates and formats). multilingual and languages say which languages the feed comes in. true takes any language a channel's subscription chooses and translates the feed into it by AI, which may change the wording of what a regulator cares about; languages is then ["en"], the language its own code writes. false comes in the languages languages names, its default first, every one written by the feed's own code and never by AI (see Subscriptions). type says how its content is made: evergreen (written once with AI, reviewed by a person, released on a schedule, not news), live (new data from public sources on every run, its wording maybe polished and translated by AI) or official (straight from the authority or licensed provider that publishes the data, or worked out by code; wording and languages fixed in code, no AI at any step). plans lists the plans the feed is available on, and usable says whether this workspace's plan is one of them: a feed with usable false is listed so it can be seen, and subscribing a channel to it is refused.

GET /v1/feeds/{feedId}/reference lists the example clips a feed has, by variant and version, with the shapes each comes in. .../reference/{ratio} returns one of them, an MP4 rather than JSON; narrow it with ?variant= and ?version= (the first variant and the newest version without them, and latest is the newest too), and the version it served is in x-preview-version. A variant, version or shape with no clip is a 404, and the etag works with If-None-Match as a template's reference clip does. .../reference/{ratio}/still is one frame of that clip, a JPEG 480 pixels wide, the picture Ready-made feeds' cards show, with the same parameters, etag and 404s. A feed's clips show the style it is bound to; the template's other styles are on GET /v1/templates/{templateId}/reference.

PUT /v1/feeds switches one feed on or off for the whole workspace. It takes sourceSlug, formatSlug and enabled, and enabled must be a boolean rather than a string. Every feed starts switched on, and on its own that sends nothing: a channel still has to subscribe to it. A feed that does not exist is a 404; one the workspace is not offered, and no channel listens to, is a 403.

curl -X PUT http://localhost:4000/v1/feeds \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"sourceSlug":"crypto-spot-and-dominance","formatSlug":"crypto-board","enabled":true}'
{ "sourceSlug": "crypto-spot-and-dominance", "formatSlug": "crypto-board", "enabled": true }

Switching a feed off here stops it for every channel at once. Removing one channel's subscription is a channel operation; see Channels.

Getting a stream or a feed published​

Neither publishes anything until a channel subscribes to it: a stream with kind: "STREAM" and its streamId, a feed with kind: "BROADCAST" and its sourceSlug and formatSlug, both on POST /v1/channels/{id}/subscriptions; see Channels. The template comes from the stream or the feed, and the channel picks the cut and the build. A feed the workspace is not offered is a 403 there.

Mistakes people make​

Confusing /v1/stream with /v1/streams. One takes events, the other configures them, and they need different permissions. The prefix matching is exact, so a key with ingest:write alone gets a 403 on the plural route, and a key with streams:write alone cannot send.

Writing a field map when the producer could just emit the right shape. Ask /v1/formats/template/{templateId} what the template accepts. A map exists for data you do not control, not as the normal way in.

Nesting the map to mirror the target. It is flat. "/instrument/symbol" is one key, not two levels of object.

Pinning a template version on the stream. You cannot: the stream carries the template, and which cut and which build are the subscribing channel's choice. See Channels.

Deleting a stream to "reset" it. Its subscriptions stop with it, every channel that listened stops receiving, and the id cannot be used again. Pause it with enabled: false instead, which answers events with a 409 stream_disabled until you switch it back on.