Sending events
An event is something that happened, pushed in by your system the moment it happened. Reelwire draws it with the template the stream carries, for every channel subscribed to that stream, and hands each clip over as its channel says: by email, by webhook, or through the channel's platform connector, which is the one way Reelwire posts it itself. See Posts and jobs for what happens after. This is the integration most customers write first, and for a producer or a trading system it is often the only one.
Two routes take events. POST /v1/stream takes your own data, addressed by a stream id you
created. POST /v1/ingest/{source}/{format} takes a shipped feed's data for your account,
addressed by the feed's own slugs. Both take the same envelope apart from the address (the shipped
route also takes an origin and, for manual data, a schedule), both are idempotent on eventId,
and both have a dry run beside them.
| Method | Path | Permission |
|---|---|---|
POST | /v1/stream | ingest:write |
POST | /v1/stream/validate | ingest:write |
GET | /v1/stream | ingest:write |
POST | /v1/ingest/{source}/{format} | ingest:write |
POST | /v1/ingest/{source}/{format}/validate | ingest:write |
GET | /v1/ingest-events | received:read |
GET | /v1/ingest-events/{id} | received:read |
ingest is a write-only area: there is no ingest:read, and GET /v1/stream is reached with
ingest:write as well, so a producer key needs nothing else to send. The log of what arrived is the
Activity area, received:read, which is read-only: a reporting key can see what was sent without
being able to send, and nothing can edit the receipt for something it sent. See
Permissions.
Two helpers on the dashboard's Custom feeds screen shorten the
first hour. Get your code writes a working sender for one of your streams, in Node.js, Python,
PHP, C# or curl, starting from the stream's template example. Test tool sends one event to a
stream as you, signed in, with no key: its route,
POST /v1/streams/{id}/test, is a
person's alone, and a system sends to POST /v1/stream with its own key as below.
Find the streams a key can post to
The first call a producer should make. It needs no permission beyond the one it already has, and it answers with the route and every stream the key may address.
curl http://localhost:4000/v1/stream \
-H "Authorization: Bearer rw_your_key_here"
{
"route": "http://localhost:4000/v1/stream",
"streams": [
{ "streamId": "st_30472168d941", "name": "Enterprise 2 Stream 1", "templateId": "tpl_dd7e6b264fb8", "enabled": true },
{ "streamId": "st_87cdd2aa7d2b", "name": "Enterprise 2 Stream 2", "templateId": "tpl_39bc5fecfa42", "enabled": true },
{ "streamId": "st_696a86f10c8a", "name": "Enterprise 2 Stream 3", "templateId": "tpl_71aae4d0157f", "enabled": true }
]
}
The envelope
POST /v1/stream takes five fields, one of them optional:
| Field | Required | What it is |
|---|---|---|
streamId | yes | st_ and twelve hex characters. One of your own streams, as listed under Custom feeds. |
eventId | yes | A ULID, in Crockford base32. The idempotency key, per workspace. |
occurredAt | yes | An ISO 8601 instant with its time zone (Z or an offset such as +02:00): when the fact happened, not when you sent it. |
locale | no | The language of the free text in data. |
data | yes | An object. What belongs in it is whatever the stream's template accepts. |
There are no other fields. A templateId or a templateVersion in the body is ignored: the stream
carries the template, and a channel subscribing to the stream picks the cut and the build.
What goes in data
With every template in the catalogue, data has two halves. clipData is what is drawn in the
clip, and its fields are the template's own: for Big number, the template in the examples on this
page, a kicker, what the figure is (label), the figure (value) with its prefix and unit, its
change, a line of context, more figures and a source. metaData is what the post says beside the
clip and is never drawn, and it is the same in every template: title, the description in four
lengths (captionTiny, captionShort, captionMedium and captionLong, up to 200, 500, 1,500 and
4,000 characters) and hashtags. Each channel posts the length that fits its platform, and a
channel on No specific platform is handed all four. A length you leave out falls back to the next
shorter one you wrote, or else the next longer one. An event with no metaData has its post text
written from clipData. A field the template does not have is an error that names it (a 400 on a
shipped feed, unmapped on a stream), so a misspelt key never goes quietly undrawn.
Further languages go in data.locales. locale names the language data is written in; beside
the body, data.locales may hold, per language code and in the same shape as the body, only what
differs in that language, such as "locales": { "de": { "clipData": { "label": "..." } } }. A
channel in German draws the body with the de block laid over it, and a channel in a language the
event carries no block for draws the body as it is. Reelwire translates nothing itself.
A picture is a file from your media library, and nothing else. An image field takes
{ "url": "media://...", "alt": "..." }, where the url is media:// followed by the file's id or its
name. A web address is refused, naming the field: on a stream the event is recorded unmapped, and
the dry run answers valid: false; on a shipped feed it is a 400. Upload the picture to the
media library first, then refer to it. A reference to a file this workspace's
library does not hold is drawn as a missing picture; the stream's dry run does not look in the
library, and POST /v1/templates/{templateId}/validate
does, naming every such field.
Validate before you send
POST /v1/stream/validate takes the identical body, runs the identical mapping and validation, and
stores nothing. Write your producer against it first, and keep it in your test suite.

curl http://localhost:4000/v1/stream/validate \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"streamId": "st_30472168d941",
"eventId": "01M2SQMKK3G1NVFXV31DCBVB92",
"occurredAt": "2026-09-18T07:55:00Z",
"locale": "en",
"data": {
"clipData": {
"kicker": "At the close",
"label": "Ten-year Bund yield, a three-week high",
"value": 2.41,
"unit": "%",
"change": { "value": 0.06, "unit": "pts", "label": "on the day" },
"source": "Deutsche Finanzagentur"
},
"metaData": {
"title": "Bund yields close at a three-week high",
"captionShort": "The ten-year Bund settled at 2.41 per cent, its highest in three weeks.",
"hashtags": ["bunds", "rates"]
}
}
}'
{
"requestId": "01M2SQMVE23G7M81DJG9NZMAP2",
"streamId": "st_30472168d941",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": "1.0.0",
"valid": true,
"document": {
"clipData": {
"kicker": "At the close",
"label": "Ten-year Bund yield, a three-week high",
"value": 2.41,
"unit": "%",
"change": { "value": 0.06, "unit": "pts", "label": "on the day" },
"source": "Deutsche Finanzagentur"
},
"metaData": {
"title": "Bund yields close at a three-week high",
"captionShort": "The ten-year Bund settled at 2.41 per cent, its highest in three weeks.",
"hashtags": ["bunds", "rates"]
}
},
"warnings": [],
"errors": [],
"wouldMatch": [
{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de", "subscriptionId": "01M2985P0SKB0JK587C9ZZKRKN", "outputs": 1 },
{ "channelId": "01M2985NXJRXVE5HKCCGKASP8P", "channelName": "Channel 10 en", "subscriptionId": "01M2985P2HZGF9SGQCW7YX6QRC", "outputs": 1 }
]
}
Two things to read here. document is the event after the stream's field map has run: exactly what
the template will be handed. wouldMatch is every channel that would get a clip, so an empty array
means the event is valid and nobody is listening.
A failed validation still answers 200. valid is the field that says whether it would have
worked, not the status code:
{
"requestId": "01M2SQNGZ0NR3GZ9WV2W75XMSZ",
"streamId": "st_30472168d941",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": "1.0.0",
"valid": false,
"document": { "clipData": { "label": "Ten-year Bund yield", "valeu": 2.41 } },
"warnings": [],
"errors": [
{ "path": "/clipData", "message": "missing value" },
{ "path": "/clipData", "message": "has a field this template does not take: valeu" }
],
"wouldMatch": [
{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de", "subscriptionId": "01M2985P0SKB0JK587C9ZZKRKN", "outputs": 1 },
{ "channelId": "01M2985NXJRXVE5HKCCGKASP8P", "channelName": "Channel 10 en", "subscriptionId": "01M2985P2HZGF9SGQCW7YX6QRC", "outputs": 1 }
]
}
wouldMatch is listed even for a document that fails, since it depends on who listens rather than
on what you sent. Each error names its place as a JSON Pointer and says what is wrong in words: the field that is
missing, the field this template does not take, or, for a choice, the values it accepts (twelve at
most are listed).
A malformed envelope is different: eventId that is not a ULID, or a missing streamId, is a 400
with a problem envelope, because there is nothing to validate against. A
streamId this account does not have is a 404 unknown_stream, and a paused stream a 409
stream_disabled, on the dry run and on the real thing alike.
Send it
The same body, to /v1/stream.
curl http://localhost:4000/v1/stream \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d @event.json
{
"ingestEventId": "01M2SQN507TDA0KNTJJABHBR9A",
"requestId": "01M2SQN4ZJNMXNV98HYXRST0WZ",
"status": "accepted",
"idempotent": false,
"streamId": "st_30472168d941",
"matched": [
{
"channelId": "01M2985NX4V0WA1GV9MC63DEPJ",
"channelName": "Channel 4 de",
"subscriptionId": "01M2985P0SKB0JK587C9ZZKRKN",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": "1.0.0",
"outputs": 1,
"postGroupId": "01M2SQN50JQFC00PZE93M9E33R"
},
{
"channelId": "01M2985NXJRXVE5HKCCGKASP8P",
"channelName": "Channel 10 en",
"subscriptionId": "01M2985P2HZGF9SGQCW7YX6QRC",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": "1.0.0",
"outputs": 1,
"postGroupId": "01M2SQN50TAP4BXAYDZRC9WMBC"
}
],
"warnings": [],
"normalized": { "clipData": { "kicker": "At the close", "label": "Ten-year Bund yield, a three-week high", "value": 2.41, "unit": "%", "change": { "value": 0.06, "unit": "pts", "label": "on the day" }, "source": "Deutsche Finanzagentur" }, "metaData": { "title": "Bund yields close at a three-week high", "captionShort": "The ten-year Bund settled at 2.41 per cent, its highest in three weeks.", "hashtags": ["bunds", "rates"] } }
}
202, not 201. Rendering takes tens of seconds and the producer is not going to wait for it. Each
postGroupId is a job you can follow on Posts and jobs: it is the channel's
id there, and ingestEventId is the post's own id (its postKey), since every channel one
event reaches is one post. Once rendered, each channel's clip is handed over as the channel says, or
waits for a reviewer first where the channel asks for one.
The statuses
status | Status code | What happened |
|---|---|---|
accepted | 202 | Validated, matched at least one channel, work queued. |
no_match | 202 | Valid, and no channel takes it: no enabled subscription names this stream, or every channel that has one is paused, wears a paused brand, or is in a shape the template does not draw. Stored, nothing drawn. |
unmapped | 202 | The document, after the stream's field map, does not fit the template. Stored, nothing drawn. mappingErrors says why, field by field. |
duplicate | 200 | This eventId already landed. The original outcome stands. |
stale | 202 | Shipped feeds only: valid, and older than the feed accepts (its maxAgeSeconds in the catalogue, a day for the ready-made feeds). Stored, nothing drawn. |
Only accepted produces posts. Treat no_match and stale as success, because they are: your
event was well formed and was stored, it did not warrant a post. Treat unmapped as the bug it is,
and ask /v1/stream/validate what it wants.
Idempotency: eventId is the key
eventId is a ULID you generate, once, for one thing that happened. It is the idempotency key, per
workspace, and it is what makes a retry safe: a timed-out request can be sent again without making a
second post.
Resending the same eventId with the same body is a no-op, answered 200 with status: "duplicate" and idempotent: true:
{
"ingestEventId": "01M2SQN507TDA0KNTJJABHBR9A",
"requestId": "01M2SQN52VVB9MDKGQ6AQ2TBRH",
"status": "duplicate",
"idempotent": true,
"streamId": "st_30472168d941",
"matched": [],
"warnings": []
}
ingestEventId is the same as the first send, so you can still tie the reply to the work. matched
is empty, because nothing new was matched; look up the original event if you need the list.
Resending the same eventId with a different body is refused, because that is always a producer
bug rather than a retry:
{
"error": {
"type": "https://reelwire.io/errors/idempotency_conflict",
"code": "idempotency_conflict",
"message": "eventId 01M2SQMKK3G1NVFXV31DCBVB92 was already used with a different body",
"requestId": "01M2SQNGWMW5F5P9CEEHDEFM1B",
"details": [
{ "path": "/eventId", "message": "Use a fresh eventId for a different payload, or resend the original body" }
]
}
}
Generate the id where the event is generated and carry it through your retries. An id minted inside the retry loop makes every retry a new event, which is the exact failure the field exists to stop.
Shipped feeds: POST /v1/ingest/{source}/{format}
Where a feed is one Reelwire carries and you are pushing its data for your own account, the address
is the feed's source and format slugs in the path, and the body has no streamId. It takes
eventId, occurredAt, origin (shipped, custom or manual, defaulting to shipped), an
optional locale and data. A schedule (publishAt and timezone) is accepted only with
origin: "manual"; shipped and custom data go out when they arrive. What data may hold is the
key mapping of the template the feed is drawn with: ask
GET /v1/formats/feed/{feedId} for it. Content that
does not fit is a 400 naming the field under /data, on the dry run as on the real call: unlike the
stream's dry run, this one does not answer valid: false. A source or format slug that does not
exist is a 404 (unknown_source, unknown_format, which lists the formats the source has).
curl http://localhost:4000/v1/ingest/crypto-spot-and-dominance/crypto-board/validate \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"eventId": "01M2SQMKK3G1NVFXV31DCBVB96",
"occurredAt": "2026-09-18T08:00:00Z",
"data": {
"clipData": {
"title": "The eight largest coins and how they moved in 24 hours",
"items": [
{ "label": "Bitcoin", "value": 85788, "prefix": "$", "change": 0.42 },
{ "label": "Ethereum", "value": 2700.79, "prefix": "$", "change": -0.23 }
]
},
"metaData": { "title": "Crypto board" }
}
}'
{
"requestId": "01M2SQZHQ6K0Y55BA1MK2JG53B",
"valid": true,
"normalized": {
"clipData": {
"title": "The eight largest coins and how they moved in 24 hours",
"items": [
{ "label": "Bitcoin", "value": 85788, "prefix": "$", "change": 0.42 },
{ "label": "Ethereum", "value": 2700.79, "prefix": "$", "change": -0.23 }
]
},
"metaData": { "title": "Crypto board" }
},
"wouldMatch": [
{ "channelId": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "channelName": "Channel 1 en", "subscriptionId": "01M29DSF6S2JYYXQQM4XHJMGK7", "templateId": "tpl_06dc2554e5ac", "templateVersion": "1.0.0", "outputs": 1 },
{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de", "subscriptionId": "01M29DSF7F2JT2N5FXSF126CSF", "templateId": "tpl_06dc2554e5ac", "templateVersion": "1.0.0", "outputs": 1 }
],
"warnings": [],
"stale": false
}
Note the shape is not quite the stream's: this route answers with normalized rather than
document, and carries a stale flag. wouldMatch is empty, with a warning, when the feed is
switched off for this workspace under Ready-made feeds, and normalized is left out while no channel
of yours listens to the feed. POST /v1/ingest/{source}/{format} itself answers 202
with ingestEventId, status, idempotent, matched and normalized, whatever the status: unlike
the stream route, a duplicate here is a 202 too, and its matched lists the channels the first
send reached.
Declaring which account you are sending for
A producer that handles several customers can assert which one this request is for, with the
x-reelwire-account header. The bearer key still decides where the event lands; the header only
says what you believe, and a disagreement is refused rather than quietly filed under whoever the key
belongs to.
curl http://localhost:4000/v1/ingest/crypto-spot-and-dominance/crypto-board \
-H "Authorization: Bearer rw_your_key_here" \
-H "x-reelwire-account: 01M1VFRWE3A35H892VAAEKKDA7" \
-H "Content-Type: application/json" \
-d @event.json
{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "This credential does not belong to the account this request declares",
"requestId": "01M2SR1WZW7Q0N6A1E039XG2YT",
"details": []
}
}
Without the header, picking up the wrong key writes one customer's data into another's workspace and returns 202, and the mistake is only visible later, in somebody else's dashboard. The header costs nothing and closes that.
Reading an event back
GET /v1/ingest-events lists what arrived, newest first, paged as every log is
(see Lists and paging).
curl "http://localhost:4000/v1/ingest-events?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"events": [
{
"id": "01M2SQN507TDA0KNTJJABHBR9A",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"eventId": "01M2SQMKK3G1NVFXV31DCBVB92",
"status": "ACCEPTED",
"occurredAt": "2026-09-18T07:55:00.000Z",
"receivedAt": "2026-09-18T07:45:50.088Z"
}
],
"total": 1,
"all": 1,
"page": 1,
"perPage": 1,
"facets": { "status": ["ACCEPTED"], "origin": ["custom"] }
}
It filters on status (ACCEPTED, NO_MATCH, UNMAPPED or STALE) and origin (shipped,
custom or manual), and searches the names the row prints: the feed, the stream or the post list.
GET /v1/ingest-events/{id} returns one event in full, with its normalized document and every post
group it produced.
What to know about POST /v1/broadcast
/v1/broadcast, /v1/broadcast/validate, /v1/broadcast/{id}, /v1/broadcast/pictures,
/v1/broadcast/schedules, /v1/broadcast/log and /v1/broadcasts appear in the route list, and they are not yours: they are
listed under "Not available to API keys". The first two are how Reelwire's own feed server delivers
the ready-made feeds, one event to every workspace with a
channel listening at once, and they need a credential issued for exactly that; a workspace key is
refused:
{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "This credential lacks the ingest:shipped scope",
"requestId": "01M2SR5FVDBNWWB37MYY9DVABF",
"details": []
}
}
The other two are the platform's own record of what a broadcast reached. Do not build against them. Your account receives broadcasts by subscribing a channel to the feed; see Channels.
Mistakes people make
Minting the eventId inside the retry loop. Then every retry is a new event, and a network
blip hands the same story over three times. Generate it once, where the fact is generated.
Sending occurredAt as "now". It is when the fact happened. A feed with a maximum age uses it
to decide whether the event is still worth posting, and sending the clock instead makes a two-hour-
old replay look fresh.
Building the body by hand. Ask /v1/formats/template/{templateId} what the template accepts and
generate a client from the jsonSchema it returns. See
Templates and formats.
Treating no_match as a failure. It means no live channel takes that stream yet. Check the
channels' subscriptions, and whether those channels are paused, rather than your producer.
Reading the status code of /v1/stream/validate instead of valid. A document that does not
fit the template still answers 200 there. (A shipped feed's dry run is the exception: it answers 400.)
Putting a stream id from another account in the body. It answers 404 unknown_stream, exactly
as an id that does not exist anywhere does.