GET /v1/response_stream
The server-sent events stream of a key's project and environment — inbound messages, command settlements and, on request, receipts, delivery verdicts, shared locations and a contact's edits and unsends, each resumable by id.
GET /v1/response_streamOne long-lived server-sent
events
connection carries what happens to your key's project in your key's
environment: every inbound message as a message frame, and every command's
terminal status as a command frame. Ask for them and it also carries a
receipt frame for each message a recipient's device delivered or read, and a
delivery_failed frame for each message the provider accepted and later
reported not delivered, a location frame for each change to a location a
contact shares with the line, a moderation frame for each message a
contact edits or unsends, and a poll frame for each moment of a poll. A stream that ends because your key or your project
stopped working says so in an error frame before it closes.
The frames are served from Mapier's own stored events in both
environments, so a test key and a live key read the same shapes, and every
frame but the closing error carries an id: you can resume from.
The stream resumes
Every frame carries an id:, and reconnecting with Last-Event-ID resumes where you left off —
see Resuming. There is no separate catch-up call.
Anatomy of a connection
curl -N https://api.mapier.ai/v1/response_stream \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Accept: text/event-stream"Response headers:
HTTP/1.1 200 OK
content-type: text/event-stream; charset=utf-8
cache-control: no-store
x-accel-buffering: noThe first bytes on the wire are a reconnect hint and a comment confirming the stream is live:
retry: 3000
: connectedThen, every 25 seconds, a heartbeat — on a fixed interval, whether or not frames were written in between:
: pingLines starting with : are SSE comments, not events. Five are written:
: connected, : ping, : catching up and : replay window exceeded (the
last two under Resuming), and : bye when the
server is being replaced during a deploy — reconnect on the retry: it gave
you. A client that acts only on event: and data: lines ignores all of them,
which is correct.
Query parameters and headers
| Parameter | In | |
|---|---|---|
events | query | Frames to add beyond message and command, comma-separated. See Asking for more frames. |
channel | query | Send only the frames about conversations on this channel. One value. See Narrowing a stream. |
conversationId | query | Send only the frames about this conversation. One value, a uuid. |
Last-Event-ID | header | The id: of the last frame you processed. See Resuming. |
x-mapier-version | header | Read on every route; nothing this operation does differs between the versions yet. |
A value events, channel or conversationId does not accept is a
422 validation_failed before the stream opens. Pass events once, as one
comma-separated value: ?events=receipts&events=delivery_failures repeats the
parameter, and only the first is read, so no delivery_failed frame arrives
and nothing says why. A repeated channel or conversationId, by contrast, is
refused. A Last-Event-ID that is not a uuid is ignored rather than refused
(see Resuming).
Asking for more frames
Omit events and the stream is exactly two frames, message and command.
Each value adds one frame:
events value | Adds the frame | For every message |
|---|---|---|
receipts | receipt | a recipient's device reported delivered or read |
delivery_failures | delivery_failed | the provider accepted and later reported not delivered — Apple's "Not Delivered" |
locations | location | a contact's shared location started, moved or ended |
moderation | moderation | a contact edited or unsent a message they sent |
polls | poll | a poll was created, its choices changed, someone voted or took their vote back |
The values combine: ?events=receipts,delivery_failures,locations,moderation,polls. A value not listed
here is a 422 whose details entry names it in unknown and lists the values
this stream frames in supported:
{
"error": {
"code": "validation_failed",
"message": "That stream option is not one this API frames.",
"details": [
{
"path": "events",
"message": "`events` takes a comma-separated list of: receipts, delivery_failures, locations, moderation, polls.",
"unknown": ["receipt"],
"supported": ["receipts", "delivery_failures", "locations", "moderation", "polls"]
}
]
}
}Narrowing a stream
channel sends only the frames about conversations on that channel, and takes
one of imessage · rcs · sms · whatsapp · voice · telegram ·
signal · apple_business · discord · slack · messenger. A frame's channel is its
conversation's, so a command frame for a command that names no conversation
is on a stream without channel and on no channel's.
conversationId sends only the frames about one conversation: its inbound
messages, the commands that name it and — on a stream opened with an events
value — the receipt, delivery_failed, location, moderation and poll
frames about it. It must be
a conversation in your key's project and environment, and on channel when you
give both. Otherwise the answer is a 422: at conversationId for a
conversation this key cannot stream, or at channel — carrying the channel the
conversation is on in the entry's own channel — when the two disagree. More
than one value for either parameter is a 422 too.
A filter narrows what a stream sends, not what it reads: a narrowed stream
reads the project's events as an unnarrowed one does and sends the frames that
match, so it costs what any stream costs and counts toward the same four open
streams. Narrow by channel, or follow one conversation; to follow many, open
one stream and read conversationId off the message, command, location, moderation and poll frames.
The receipt and delivery_failed frames carry none: correlate them by
commandId or messageId.
Resuming with Last-Event-ID
Every frame but error carries an id: line before its event: line. Keep
the id: of the last frame you finished processing, and send it as
Last-Event-ID when you reconnect:
curl -N https://api.mapier.ai/v1/response_stream \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 0192a7c4-5b1e-7c3d-9f2a-6e8d4b1c0a37"- At most 500 are replayed per batch. The stream sends the frames after that
id, 500 at a time, in order. If you were further behind, nothing is dropped:
the rest follows on the same connection, and a
: catching upcomment marks each batch that stopped at the cap, so 500 frames and then silence is not mistaken for having caught up. - Events are kept for 90 days, which is how far back an id can resume. An
id older than that is not an error: you are sent everything that remains
after it, preceded by a
: replay window exceededcomment so you can tell it from a quiet period. - On a narrowed stream the 500 counts the events a batch read, not the
frames it sent, so a batch may send fewer frames and still be followed by
: catching up. - A
Last-Event-IDthat is not a uuid is ignored. Everyid:this stream writes is one, so anything else did not come from it: the stream starts at the live edge rather than refusing it, because a browser'sEventSourcere-sends the same header on every reconnect and a refusal would come back for ever.
A frame can therefore arrive twice across a reconnect — the one you were
reading when the socket went. Apply frames by id and a repeat is a no-op.
Reconnection and delivery guarantees has a
loop that keeps the id and a parser that reads it.
Event: command
A command reached its terminal status. Emitted once per command.
id: 0192a7c4-5b1e-7c3d-9f2a-6e8d4b1c0a37
event: command
data: {"commandId":"3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71","logicalActionId":"order-2481","status":"succeeded","errorCode":null,"conversationId":"a0000000-0000-4000-8000-000000000001","type":"message.send","messageId":"0192a7c4-5b1e-7c3d-9f2a-6e8d4b1c0a99","externalId":null,"projectId":"b0000000-0000-4000-8000-000000000002","environment":"live","failureClass":null}Prop
Type
A 202 — an adopted one included — says the command was queued; this frame
says how it ended. For message.send, succeeded is the transport's
settlement of the send and not a delivery receipt — delivery is a
receipt frame on a stream opened with ?events=receipts,
and a provider's later "not delivered" is a
delivery_failed frame, which moves no status.
Switch on failureClass
errorCode is the transport's own word and the set is open; failureClass is
closed, the same for every transport, and it is the one to branch on — what
each value means is in Error codes.
The same class rides the command.settled and message.failed webhooks, and
the 409 that hands a retried Idempotency-Key the first attempt's code.
Event: message
Someone sent a message to one of your lines. Your own sends do not echo here.
id: 0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d10
event: message
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","from":"+14155550100","text":"is it here yet?","externalId":"guid-1","replyToExternalId":null,"occurredAt":"2026-08-24T18:03:11.000Z","attachmentIds":[],"attachments":[],"messageId":"0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22"}Prop
Type
text is always present in the JSON and can be null. A photo sent with no caption arrives with
text: null and a populated attachmentIds, so code that reaches straight for
event.text.toLowerCase() throws the first time someone sends one.
A null text does not promise an attachment either. On a live iMessage line a
chat background set or removed in a conversation with one person also arrives
as a message from that person with no text, whoever made the change — a
chat_background.set or a
chat_background.remove of yours, or the person
on their own phone. Skip a received message that has no text and no attachment
rather than reply to it.
Event: receipt
Sent only on a stream opened with ?events=receipts. A recipient's device
reported a message delivered or read.
id: 0192a7c4-7d55-7a90-b3e1-4c6a8f2d9e04
event: receipt
data: {"commandId":"3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71","messageId":"0192a7c4-5a31-7bd2-8e6f-1d0c9b8a7f65","status":"delivered","occurredAt":"2026-08-24T18:03:14.000Z","simulated":false}Prop
Type
In test the sandbox produces receipts. Live, each transport reports what it
can, and an iMessage line reports from Apple's status of the send, which is first asked about 75 seconds after the send and
last about 10 minutes after it — so on iMessage a delivery or a read later than
that is never framed, and a read arrives only from a recipient who shares read
receipts. To ask about one message now, whatever the stream said, call
GET /v1/messages/{messageId}/receipts.
Event: delivery_failed
Sent only on a stream opened with ?events=delivery_failures. The provider
accepted a message and later reported it not delivered — Apple's "Not
Delivered" on iMessage, and in test the sandbox's
Sends, then not delivered
delivery mode. It is the same fact as the
message.delivery_failed webhook.
id: 0192a7c4-8e20-7f43-a1b6-9d3c5e7f0a28
event: delivery_failed
data: {"commandId":"3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71","messageId":"0192a7c4-5a31-7bd2-8e6f-1d0c9b8a7f65","occurredAt":"2026-08-24T18:05:02.000Z","simulated":false}Prop
Type
That send is not a failed one. The message keeps its status, the frame
carries no status, errorCode or failureClass, and the send's own
command frame still arrives — possibly after this one, because a live
verdict can land before the transport's settlement does. Correlate by
commandId, and do not assume you recorded the send before the verdict came.
Event: location
Sent only on a stream opened with ?events=locations. A contact sharing their
location with the line started, sent a new position, or stopped — on iMessage
from Apple's Find My, about every 30 seconds per contact sharing, a repeated
position framed once. The same facts as the location.share_started,
location.updated and location.share_ended webhooks.
id: 0192a7c4-9b31-7c02-8d44-2f6e1a3b5c70
event: location
data: {"type":"location.updated","conversationId":"a0000000-0000-4000-8000-000000000001","contactId":"c0000000-0000-4000-8000-000000000003","latitude":40.76203,"longitude":-73.985918,"horizontalAccuracyM":12,"capturedAt":"2026-09-30T18:07:00.000Z","occurredAt":"2026-09-30T18:07:00.000Z","simulated":false}Prop
Type
A location is filed only on a one-to-one conversation and a contact this project already holds, so a person who shares before any message between you is not framed. The coordinates are a person's, and you are responsible for how you store and use them. See Location events.
Event: moderation
Sent only on a stream opened with ?events=moderation. A contact edited or
unsent a message they sent to the line. One frame name for both, told apart by
type: the same facts as the message.edited and message.unsent webhooks.
id: 0192a7c4-a412-7e19-b0c5-3d7f2a9e6b18
event: moderation
data: {"type":"message.edited","messageId":"0192a7c4-6c02-7d4e-9a13-5b8f2c1d0e44","conversationId":"a0000000-0000-4000-8000-000000000001","externalId":"guid-1","text":"See you at 7","previousText":"See you at 6","editedAt":"2026-09-30T18:10:04.000Z","simulated":false}Each names the message by the messageId and externalId its message frame
carried. The message's stored text is not rewritten — an edit's new text
arrives here alone — and an unsent message is not deleted. Only a message this
platform filed is framed, so an edit of one sent before the line was connected
is not. The line's own edits and unsends are command frames, never these.
message.edited
Prop
Type
message.unsent
Prop
Type
See Edits and unsends.
Event: poll
Sent only on a stream opened with ?events=polls. A poll in a conversation
was created, had its choices changed, got a vote, or had a vote taken back —
a contact's poll, or the line's own. One frame name for all four, told apart by
type: the same facts as the poll.created, poll.options_changed,
poll.voted and poll.vote_cleared webhooks.
id: 0192a7c4-b5e3-7a41-9c28-4e1f6a0b2d93
event: poll
data: {"type":"poll.created","conversationId":"a0000000-0000-4000-8000-000000000001","actor":"line","contactId":null,"pollExternalId":"2F6B1C3D-8E4A-4B5C-9D7E-0A1B2C3D4E5F","question":"Lunch?","options":[{"optionExternalId":"A1B6D8F0-9C2E-4D5F-8A4B-6C8D0E1F2A3B","text":"Tacos"},{"optionExternalId":"B2C7E9A1-0D3F-4E6A-8B5C-7D9E1F2A3B4C","text":"Ramen"}],"occurredAt":"2026-10-01T18:11:40.000Z","simulated":false}Prop
Type
Your own poll streams here too, with actor: line: its pollExternalId and
its choices' optionExternalIds — what poll.vote takes — arrive on this frame
and nowhere else.
Event: error
The stream is ending, and this is why. The socket closes after it, and the
frame carries no id: — there is nothing after it to resume.
event: error
data: {"code":"invalid_credentials","message":"That API key has been revoked."}Prop
Type
The credential is re-proved every 60 seconds, so a revoked or expired key, or
an archived project, closes the stream within a minute even if nothing else
noticed — with invalid_credentials or project_archived here rather than a
dropped socket. Neither heals on a reconnect. A socket that closes with no
error frame — a network blip, a deploy's : bye — is ordinary: reconnect
with your Last-Event-ID.
Forward compatibility
Fields are added to frames without a version change, and a frame can carry keys this page does not list. Two habits keep a client working:
- Read the fields you need by name. Do not reject a frame for an extra key.
- Tolerate unknown values and unknown
event:names.errorCodeis open, and a new frame name is ignored rather than thrown on. Give everyswitcha default:
switch (event.status) {
case "succeeded":
markSettled(event.commandId); // settled, not delivered
break;
case "failed":
case "cancelled":
case "expired":
markNotDone(event.commandId, event.failureClass, event.errorCode);
break;
case "ambiguous":
case "no_effect":
markOutcomeUnproven(event.commandId, event.errorCode);
break;
default:
markOutcomeUnproven(event.commandId, event.errorCode);
}Reconnecting
The stream drops periodically even when healthy, so the outer loop is
unconditional: connect with the last id you processed, consume until the socket
closes, wait, connect again. The 429 in the table below counts
connections, not frames.
const BASE = "https://api.mapier.ai";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const onFrame = (name: string, data: unknown) => {
console.log(name, data);
};
const MIN_DELAY = 6_000;
const MAX_DELAY = 30_000;
let lastEventId: string | undefined; // keep this somewhere durable
let delay = MIN_DELAY;
for (;;) {
const startedAt = Date.now();
try {
const res = await fetch(`${BASE}/v1/response_stream?events=receipts,delivery_failures`, {
headers: {
Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
Accept: "text/event-stream",
...(lastEventId ? { "Last-Event-ID": lastEventId } : {}),
},
});
if (res.status === 429) {
await res.body?.cancel();
await sleep(Math.max((Number(res.headers.get("retry-after")) || 1) * 1_000, MIN_DELAY));
continue;
}
if (!res.ok || !res.body) {
await res.body?.cancel();
throw new Error(`stream status ${res.status}`);
}
await consume(res.body, (id, name, data) => {
onFrame(name, data);
if (id) lastEventId = id; // only once the frame is handled
});
} catch (err) {
console.warn("stream dropped, reconnecting", err);
}
delay = Date.now() - startedAt > 30_000 ? MIN_DELAY : Math.min(delay * 2, MAX_DELAY);
await sleep(delay + Math.random() * delay);
}The parser behind consume, and what to do with a command whose settlement you
never saw, are in Reconnection and delivery
guarantees.
Multiple connections
A key may hold four streams open at once, always; a connection past that may
be refused with 429 rate_limited and details.reason: "too_many_streams",
whose short details.retryAfter means close a stream rather than wait. Every
connection receives every frame its filters admit — they are a broadcast, not a
work queue. One stream per project is all an integration needs; two workers on
one stream's frames should deduplicate on the frame's id.
Errors
| Status | Code | Cause |
|---|---|---|
401 | invalid_credentials | No key, or one that is unknown, revoked or expired |
409 | project_archived | The project is archived |
422 | validation_failed | An events value this stream does not frame, a channel it does not carry or more than one, a conversationId this key cannot stream, or an x-mapier-version this build does not answer to; details names the field |
429 | rate_limited | 10 connects per minute, per key; or more than 4 open at once (details.reason: "too_many_streams", whose short details.retryAfter means close a stream rather than wait) |
500 | internal_error | An unhandled failure, reported; details.requestId names the log line when present |
503 | upstream_unavailable | The service could not open the stream |
503 | service_busy | The platform had no database connection free in time; wait details.retryAfter seconds and connect again |
Quick reference
| Frames | message, command, error; receipt, delivery_failed, location, moderation and poll on request (?events=) |
| Narrowing | ?channel=, ?conversationId= — one value each |
| Heartbeat | : ping every 25s |
| Resume | Last-Event-ID; at most 500 per batch; events kept 90 days |
| Revocation | re-proved every 60 seconds; an error frame, then the close |
| Concurrent streams | allowed, all receive every frame their filters admit |
| Rate limit | 10 connects per minute, per key; 4 open at once, per key |
Related
Reconnection and delivery guarantees
A parser that keeps the id, and resuming with Last-Event-ID.
Receipts and delivery verdicts
receipt and delivery_failed, and the webhooks that carry the same facts.
GET /v1/messages/{messageId}/receipts
Ask what one message's receipts say, now.
Versions
The x-mapier-version header every route reads.
Command settlement events
What each terminal status means once you have parsed it.