Server-Sent Events for WebSocket channels, multipart/form-data in api_call_send, admin_logs GraphQL query
October 6, 2026
NEW
- Subscribe to a channel over Server-Sent Events: A page can now open a WebSocket channel with
EventSourceinstead of a WebSocket, at/anycable-events. There is nothing to enable on the Instance and no channel code to change: an SSE subscriber drives exactly the same backend path a WebSocket subscriber does, so yourchannels/:channel_name/subscribed.liquidpartial authorizes it as before, thewebsockets_require_subscribed_partialandwebsockets_require_csrf_tokenflags in app/config.yml apply unchanged, and broadcasts - including those sent from thechannel_send_messageGraphQL mutation - reach SSE and WebSocket subscribers alike.
const identifier = JSON.stringify({ channel: 'conversate', room_id: '12' });
const url = `/anycable-events?identifier=${encodeURIComponent(identifier)}`
+ `&authenticity_token=${encodeURIComponent(token)}`;
const source = new EventSource(url);
source.onmessage = (event) => showMessage(JSON.parse(event.data));
The stream opens with welcome and confirm_subscription events, and each broadcast then arrives as a plain message event whose data is the payload itself - not the Action Cable envelope a WebSocket client unwraps. Five things differ from the WebSocket transport:
- Pass the whole identifier as URL-encoded JSON. The
?channel=conversate&room_id=12shorthand carries only the channel name;room_idis dropped before the server ever sees it, so every client that uses the shorthand ends up in one sharedroom_id: nullroom instead of its own. Any per-room channel must useidentifier. - The connection is read-only. SSE is server-to-client only: there is no counterpart to
subscription.send(), and achannels/:channel_name/receive.liquidpartial is never invoked over this transport. A channel that defines one is still perfectly subscribable over SSE - a room with two-way WebSocket chat can be read this way in parallel - it is only the sending half that has no SSE equivalent. - The CSRF token travels in the query string.
EventSourcecannot set request headers, so withwebsockets_require_csrf_tokenon (the default for new Instances) the token goes in the URL asauthenticity_token, exactly as it already does for the WebSocket URL. An anonymous visitor - the usual case for a read-only broadcast stream - is not given theCSRF-TOKENcookie, so template the token into the page with{{ context.authenticity_token }}instead. - Native reconnect will not refresh the token.
EventSourceretries the URL it was constructed with, so once a token goes stale (a logout, a new session) the built-in retry can only fail. Handleonerrorby constructing a newEventSourcewith a fresh token. - A refused subscription ends the stream. There is no
rejected()callback here - the server answers401and the stream closes, which surfaces asonerror. Rejection reasons are recorded in your Instance logs as before, including theWebSocketSubscribeErrorentry written when asubscribedpartial raises.
One subtlety worth knowing if your channel has no subscribed partial and you run with websockets_require_subscribed_partial: false, where same-origin is what admits a subscriber: browsers send no Origin header at all on a same-origin EventSource (they only add it to cross-origin requests), so on this transport an absent Origin is read as same-origin. A cross-origin EventSource does send the header and is evaluated exactly as a WebSocket is. An Origin that is present but opaque - null from a sandboxed iframe, a file:// page - stays untrusted on every transport.
multipart/form-datarequests withapi_call_send: Theapi_callargument of theapi_call_sendmutation accepts a newform_datalist, so you can upload files to an external API - for example a document together with a JSON description of it - without hand-building aPOST_MULTIPARTbody - see Sending Files Using API Calls. Each part has anameand exactly one of a textvalueor afile; a file is given either byurl(downloaded before the request is sent, e.g. the URL of an uploaded property) or bycontent_base64(for content generated in Liquid). Parts are sent in the order you list them and names may repeat (e.g.files[]).
mutation upload($url: String!, $headers: HashObject, $photo_url: String!, $file_info: String!) {
api_call_send(
api_call: {
url: $url
method: "POST"
headers: $headers
form_data: [
{ name: "uploadedFile", file: { url: $photo_url, filename: "photo.jpeg", content_type: "image/jpeg" } }
{ name: "fileInfo", value: $file_info, content_type: "application/json" }
]
}
) {
response { status body }
}
}
-
Methods.
form_dataworks withPOST,PUTandPATCH, and cannot be combined withbody- the mutation returnsapi_call accepts either body or form_data, not both. -
Content-Type is set for you. The
multipart/form-dataheader, including its boundary, is generated automatically; aContent-Typeyou pass inheadersis ignored forform_datarequests, so a header copied from acurlexample will not break the body. -
Optional fields.
file.filenamedefaults to the last segment of theurl(orfile), andfile.content_typetoapplication/octet-stream. A text part'scontent_typeis only sent when you set it. -
Limits. A request can have up to 100 parts and download at most 10 files by
url; all files and values together share a 50MB limit, which is enforced while the files download. -
Logs. The Sent Notifications entry records a summary of the parts - names, file URLs, filenames and content types, and shortened text values - never the file contents.
-
Read your Instance logs with the
admin_logsGraphQL query: The entries you write with thelogtag - the same onespos-cli logsstreams - can now be queried from GraphQL, so you can build a log viewer into your own admin pages. Each entry has anid,type,message,dataandcreated_at, and results are always returned oldest first.
query logs($last_id: ID) {
admin_logs(
filter: {
id: { range: { gt: $last_id } }
type: { value: "payment" }
message: { contains: "declined" }
}
) {
id
type
message
data
created_at
}
}
- Following the log. Without a lower
idbound you get the latest matching entries (20 by default); withid: { range: { gt: <id of the last entry you saw> } }you get only the entries after it (up to 500 by default). Pass the lastidback on each call to poll for new entries.per_pageis capped at 500. - Filtering.
typeandmessageaccept the usualStringFilteroperators.valueis an exact match, whilecontains,starts_withandends_withignore case unless you setcase_sensitive: true. Filters are applied to the most recent part of the log (about the latest 1000 entries), not its whole history.
IMPROVED
-
pos-cliand the Instance API report a Partner Portal outage as an outage: An Instance checks every API token with the Partner Portal. While the Portal was briefly unreachable - during a deploy, a restart or rate limiting - the check had no answer, yet the Instance responded with the same401as for a revoked token, andpos-clisuggested runningpos-cli env refresh-token, which could not help. The Instance now responds503 Service Unavailablewith aRetry-Afterheader and the error codepartner_portal_unavailable, together with a message explaining that the token itself is fine. An up-to-datepos-cliwaits and retries instead of failing the command, so a deploy started during a short Portal outage pauses rather than fails. A401or403from the Portal is still reported as401, and nothing is authorized while the token cannot be verified. -
More reliable Instance creation from the Partner Portal: Finishing the setup of a new Instance is now retried after a short, temporary failure, and the result is always reported back to the Partner Portal once retries are exhausted. Previously a single hiccup could leave the Instance shown as pending in the Partner Portal indefinitely.
-
The
download_filesize limit is enforced during the download: Themax_sizeargument of thedownload_filefilter was checked only against theContent-Lengththe remote server declared, so a server that omitted the header - or did not supportHEADrequests - could send a file of any size. The limit is now applied to the bytes actually received, and the download stops as soon as it is exceeded. The maximum you can request remains 50MB.
FIXED
-
A WebSocket message no longer needs the CSRF token repeated in the subscription identifier: With
websockets_require_csrf_tokenon (the default for new Instances), the token checked for everysubscription.send(...)was read from the subscription identifier alone. A client that passed its token in the consumer URL -createConsumer('/websocket?authenticity_token=...'), which is what the guide shows - connected fine but had its first message refused, and the socket closed withCan't verify CSRF token authenticity.The token the socket was opened with is now used whenever the identifier carries none; supplying it in the identifier still works, and still takes precedence. -
An API key shared across Instances is no longer refused on all but one of them: An API key presented to several Instances - such as the Partner Portal's Main Access key - could be answered with
401on every Instance except the first one that checked it, for up to 10 minutes. Each Instance now verifies the key for itself. -
.txtpages are always served astext/plain: A page with thetxtformat, such asrobots.txt, was returned astext/htmlto clients that sendAccept: */*- which includescurl,fetch()and search engine crawlers. It is now served astext/plainregardless of theAcceptheader. -
Default Instance domains stay out of search engines, assets included: The
X-Robots-Tag: noindex, nofollowheader that keeps an Instance's default platformOS domain (for examplemy-app.staging.oregon.platform-os.com) out of search results is now sent on every response from that domain, including assets and error pages, not only on pages. Your own custom domains are not affected. -
A missing asset returns
404: Requesting an asset that does not exist now returns404 Not Foundinstead of403 Forbidden. -
admin_background_jobsrefuses atimestampfilter that is not a date: Atimestampvalue that could not be read as a date or time, such asgt: "tomorow", was treated as zero, so the query quietly matched every job, or none. The query now returns the errortimestamp must be a date or a time, got: .... -
Log entries no longer go missing: Some entries written with the
logtag, as well as errors logged for your Instance, were occasionally lost, so they never appeared in your logs and could not be fetched withpos-cli logs. A single failed delivery to the service that forwards your logs could cause entries after it to be lost too. The connection to that service now recovers on its own after a failure, so one failed delivery no longer affects the entries that follow.