Skip to main content

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.

MethodPathPermission
POST/v1/streamingest:write
POST/v1/stream/validateingest:write
GET/v1/streamingest:write
POST/v1/ingest/{source}/{format}ingest:write
POST/v1/ingest/{source}/{format}/validateingest:write
GET/v1/ingest-eventsreceived: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:

FieldRequiredWhat it is
streamIdyesst_ and twelve hex characters. One of your own streams, as listed under Custom feeds.
eventIdyesA ULID, in Crockford base32. The idempotency key, per workspace.
occurredAtyesAn ISO 8601 instant with its time zone (Z or an offset such as +02:00): when the fact happened, not when you sent it.
localenoThe language of the free text in data.
datayesAn 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.

An event matched to every listening channel, each on its exact template version, and another event refused before anything is drawn because a field is too long

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​

statusStatus codeWhat happened
accepted202Validated, matched at least one channel, work queued.
no_match202Valid, 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.
unmapped202The document, after the stream's field map, does not fit the template. Stored, nothing drawn. mappingErrors says why, field by field.
duplicate200This eventId already landed. The original outcome stands.
stale202Shipped 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.