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 and posts it to every channel subscribed to that stream. 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, 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 Received 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.

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_a9c3fdb1b5c9", "enabled": true },
{ "streamId": "st_87cdd2aa7d2b", "name": "Enterprise 2 Stream 2", "templateId": "tpl_1cbd9f79150f", "enabled": true },
{ "streamId": "st_696a86f10c8a", "name": "Enterprise 2 Stream 3", "templateId": "tpl_5942090684a4", "enabled": true }
]
}

The envelope​

POST /v1/stream takes five fields, two of them optional:

FieldRequiredWhat it is
streamIdyesst_ and twelve hex characters. One of your own streams.
eventIdyesA ULID, in Crockford base32. The idempotency key, per workspace.
occurredAtyesAn ISO 8601 instant: 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 the reference templates, data has two halves. clipData is what is drawn in the clip: the headline, a second line, figures, points, pictures and a source. metaData is what the post says beside the clip and is never drawn: title, the caption in three lengths (captionShort, captionMedium, captionLong) and hashtags. Each channel posts the length that fits its platform, and a channel with Email switched on is handed all three. An event with no metaData has its post text written from clipData.

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 and the refusal names the field: 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.

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": {
"headline": "Bund yields close at a three-week high",
"subline": "The ten-year settled at 2.41 per cent",
"figures": [{ "label": "10-year Bund", "value": 2.41, "unit": "%", "direction": "up" }],
"source": "Deutsche Finanzagentur"
},
"metaData": {
"title": "Bund yields close at a three-week high",
"captionShort": "The ten-year settled at 2.41 per cent.",
"hashtags": ["bunds", "rates"]
}
}
}'
{
"requestId": "01M2SQMVE23G7M81DJG9NZMAP2",
"streamId": "st_30472168d941",
"templateId": "tpl_a9c3fdb1b5c9",
"templateVersion": "1.0.0",
"valid": true,
"document": {
"clipData": {
"headline": "Bund yields close at a three-week high",
"subline": "The ten-year settled at 2.41 per cent",
"figures": [{ "label": "10-year Bund", "value": 2.41, "unit": "%", "direction": "up" }],
"source": "Deutsche Finanzagentur"
},
"metaData": {
"title": "Bund yields close at a three-week high",
"captionShort": "The ten-year settled at 2.41 per cent.",
"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_a9c3fdb1b5c9",
"templateVersion": "1.0.0",
"valid": false,
"document": { "clipData": { "subline": "No headline here" } },
"warnings": [],
"errors": [{ "path": "/clipData", "message": "missing headline" }],
"wouldMatch": [{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de", "subscriptionId": "01M2985P0SKB0JK587C9ZZKRKN", "outputs": 1 }]
}

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_a9c3fdb1b5c9",
"templateVersion": "1.0.0",
"outputs": 1,
"postGroupId": "01M2SQN50JQFC00PZE93M9E33R"
},
{
"channelId": "01M2985NXJRXVE5HKCCGKASP8P",
"channelName": "Channel 10 en",
"subscriptionId": "01M2985P2HZGF9SGQCW7YX6QRC",
"templateId": "tpl_a9c3fdb1b5c9",
"templateVersion": "1.0.0",
"outputs": 1,
"postGroupId": "01M2SQN50TAP4BXAYDZRC9WMBC"
}
],
"warnings": [],
"normalized": { "clipData": { "headline": "Bund yields close at a three-week high", "subline": "The ten-year settled at 2.41 per cent", "figures": [{ "label": "10-year Bund", "value": 2.41, "unit": "%", "direction": "up" }], "source": "Deutsche Finanzagentur" }, "metaData": { "title": "Bund yields close at a three-week high", "captionShort": "The ten-year settled at 2.41 per cent.", "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.

The statuses​

statusStatus codeWhat happened
accepted202Validated, matched at least one channel, work queued.
no_match202Valid, and no enabled subscription names this stream. 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. 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 posting twice.

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. Content that does not fit the feed's template is a 400 naming the field under /data.

curl http://localhost:4000/v1/ingest/brk-test-feeds/brk-feed-1/validate \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"eventId": "01M2SQMKK3G1NVFXV31DCBVB96",
"occurredAt": "2026-09-18T08:00:00Z",
"data": {
"clipData": { "headline": "A shipped feed event" },
"metaData": { "title": "A shipped feed event" }
}
}'
{
"requestId": "01M2SQZHQ6K0Y55BA1MK2JG53B",
"valid": true,
"normalized": {
"clipData": { "headline": "A shipped feed event" },
"metaData": { "title": "A shipped feed event" }
},
"wouldMatch": [
{ "channelId": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "channelName": "Channel 1 en", "subscriptionId": "01M29DSF6S2JYYXQQM4XHJMGK7", "templateId": "tpl_a9c3fdb1b5c9", "templateVersion": "1.0.0", "outputs": 1 },
{ "channelId": "01M2985NX4V0WA1GV9MC63DEPJ", "channelName": "Channel 4 de", "subscriptionId": "01M29DSF7F2JT2N5FXSF126CSF", "templateId": "tpl_a9c3fdb1b5c9", "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 Feeds. 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.

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/brk-test-feeds/brk-feed-1 \
-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} and /v1/broadcasts appear in the route list, and they are not yours: they are listed under "Not available to API keys". The first is Reelwire's own generator pushing one event to every entitled customer at once, and it needs 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 publishes the same story 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 nothing subscribes to that stream yet. Check the channel's subscriptions rather than your producer.

Reading the status code of /validate instead of valid. A document that does not fit the template still answers 200.

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.