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.
| 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 | ingest:write |
GET | /v1/ingest-events/{id} | ingest:write |
ingest is a write-only area: there is no ingest:read, and the reads above are reached with
ingest:write as well. A producer key needs nothing else. 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:
| Field | Required | What it is |
|---|---|---|
streamId | yes | st_ and twelve hex characters. One of your own streams. |
eventId | yes | A ULID, in Crockford base32. The idempotency key, per workspace. |
occurredAt | yes | An ISO 8601 instant: 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.
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.
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 five statuses
status | Status code | What happened |
|---|---|---|
accepted | 202 | Validated, matched at least one channel, work queued. |
no_match | 202 | Valid, and no enabled subscription names this stream. Stored, nothing drawn. |
unmapped | 202 | A channel matched and the stream's field map did not satisfy the template. mappingErrors says why. |
stale | 202 | Valid, and older than the feed accepts. Stored, nothing drawn. |
duplicate | 200 | This eventId already landed. The original outcome stands. |
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.
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 (defaulting to shipped), an optional locale and 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. POST /v1/ingest/{source}/{format} itself answers 202 with
ingestEventId, status, idempotent, matched and normalized.
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?limit=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"] }
}
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/{id} and /v1/broadcasts are listed under ingest:write, and they
are not yours. 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.