# Realtime REST publish and request signing

> Publish an event to a realtime channel over the signed REST endpoint, and understand the accepted-but-queued result the Event Creator can return.

The Event Creator screen publishes through the ClutchCall control plane, which
signs nothing you have to think about. The **Equivalent cURL** card on that
screen shows the other path: a direct, signed `POST` to the engine's HTTP plane,
which is what your own server code does. That request carries five query
parameters — `auth_key`, `auth_timestamp`, `auth_version`, `body_md5` and
`auth_signature` — and the engine rejects it if the signature does not match.

This page documents how to build that request by hand, and what the
`queued` result on the API response card means.

## The publish endpoint and its query parameters

```
POST https://<ws-host>/apps/<app_id>/events
```

`<ws-host>` and `<app_id>` are filled in for you in the cURL card — the app id
is also shown in the pill next to the page title.

| Parameter        | Value                                                                 |
| ---------------- | --------------------------------------------------------------------- |
| `auth_key`       | Your app key. Find it, with the matching secret, in **Apps & keys**.  |
| `auth_timestamp` | Unix time in seconds. The cURL card generates it with `$(date +%s)`. |
| `auth_version`   | `1.0`.                                                                |
| `body_md5`       | Hex MD5 of the exact request body. See below.                         |
| `auth_signature` | Hex HMAC-SHA256 over the sign-string, keyed with your app secret.     |

`Content-Type` is `application/json`. Only `auth_signature` is excluded from the
material that gets signed; every other parameter above is part of it.

## Request body and encoding the data field

The body is a JSON object with three fields:

```json
{
  "name": "order.created",
  "channels": ["private-orders"],
  "data": "{\"id\":\"ord_8821\",\"total\":49,\"currency\":\"EUR\"}"
}
```

- `name` — the event name. This is the name subscribers bind to.
- `channels` — an array of channel names. The Event Creator publishes to one
  channel at a time, so the card emits a single-element array.
- `data` — **a JSON-encoded string, not a nested object.** The Data textarea in
  the console holds a JSON object; the card serialises that object and puts the
  resulting *string* in `data`. Client-side, the payload is parsed back out of
  that string.

If you build the request in your own code, the equivalent of what the card does
is `JSON.stringify(payload)` for the `data` field, then `JSON.stringify` again
for the whole envelope.

## Computing body_md5

`body_md5` is the lowercase hex MD5 digest of the serialised request body — the
same bytes you pass to `-d`. Compute it after you have finished building the
body; any whitespace or key-order change invalidates it.

```js
import { createHash } from "node:crypto";

const body = JSON.stringify({
  name: "order.created",
  channels: ["private-orders"],
  data: JSON.stringify({ id: "ord_8821", total: 49.0, currency: "EUR" }),
});

const bodyMd5 = createHash("md5").update(body, "utf8").digest("hex");
```

Because the digest covers the body, you cannot reuse a signature across two
publishes even if the channel and event are identical.

## Building the sign-string and the auth_signature HMAC

The sign-string is three lines joined with a single `\n` (no trailing newline):

```
POST
/apps/<app_id>/events
auth_key=<key>&auth_timestamp=<unix_seconds>&auth_version=1.0&body_md5=<hex_md5>
```

Line by line:

1. The HTTP method, uppercase: `POST`.
2. The request path only — no scheme, no host, no query string.
3. The query parameters, **excluding `auth_signature`**, sorted by parameter
   name and joined with `&`. Use the raw values here, not URL-encoded ones.

Sorted by name, the four parameters always come out in the order shown above:
`auth_key`, `auth_timestamp`, `auth_version`, `body_md5`. If you add any other
query parameter, it has to be folded into the same sorted list.

`auth_signature` is then the lowercase hex HMAC-SHA256 of that sign-string,
keyed with your **app secret**:

```js
import { createHmac } from "node:crypto";

const appId = "<app_id>";
const key = "<app_key>";
const secret = "<app_secret>";
const ts = Math.floor(Date.now() / 1000);

const query = [
  `auth_key=${key}`,
  `auth_timestamp=${ts}`,
  `auth_version=1.0`,
  `body_md5=${bodyMd5}`,
].join("&");

const signString = ["POST", `/apps/${appId}/events`, query].join("\n");
const signature = createHmac("sha256", secret).update(signString, "utf8").digest("hex");

const url = `https://<ws-host>/apps/${appId}/events?${query}&auth_signature=${signature}`;
```

Send the request with that URL and the exact `body` string you hashed:

```bash
curl -X POST "$URL" -H "Content-Type: application/json" -d "$BODY"
```

The app secret only ever lives on your server. It is the key to the HMAC and is
never transmitted.

## Channel and event name rules

The console validates the same shapes the control plane enforces, so a name that
the Event Creator rejects will not work over REST either:

- **Channel name** — required, at most 164 characters, and may contain only
  letters, digits and `_ - = @ , . ;`. This is the realtime protocol's channel
  grammar: a stock client refuses to subscribe to anything outside it, so
  publishing to such a name publishes into the void.
- **Event name** — required, at most 200 characters.

Channel *prefixes* are not restricted on the publish side. `private-` and
`presence-` channels are legitimate publish targets, because a signed REST
caller (or the console's control plane) is an authorized publisher. The prefix
governs who may *subscribe*, not who may publish.

## Response codes and error bodies

A successful publish returns a 2xx and the console shows the green **ok** pill
with the event and channel name.

When the engine rejects the publish, the control plane surfaces the engine's
HTTP status and its response body unchanged. On the **API response** card that
appears as a warning pill carrying the status code, the line
`Publish failed (HTTP <status>)`, and the raw response body rendered as JSON
underneath. Read that body — it is the engine's own error, not a control-plane
wrapper, so it is the same text your server SDK would see from a direct signed
call.

Two failure modes never reach the engine at all:

- **Invalid JSON in the Data field.** The console parses the textarea before
  sending and reports `Invalid JSON: <message>` inline. The Data field's
  `valid JSON` / `invalid` indicator tracks this as you type.
- **Shape validation.** An empty or malformed channel or event name is reported
  under the field and the request is not sent.

## Accepted-but-queued delivery semantics

Publishes issued from the console do not go over the signed HTTP plane. The
control plane pushes the publish body onto the engine's generic ring buffer —
`clutchcall:realtime:ring`, the same bus the presence and assist bridges
ride. That is why console publishing needs no per-deployment HTTP
configuration: the publisher is always reachable, even when the engine is not.

The consequence is a third outcome besides success and failure. The response
carries a `wired` flag:

| Result                 | Pill     | Meaning                                                                                       |
| ---------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `ok`, `wired: true`    | `ok`     | The event was handed to a heartbeating engine and fanned out across the edge mesh.            |
| `ok`, `wired: false`   | `queued` | The event was accepted onto the publish ring, but no engine is heartbeating right now.        |
| `ok: false` + `status` | status   | The engine rejected the publish. The status and body are shown verbatim.                      |

`queued` is an acceptance, not an error: the card reads *"Queued — engine
offline; will deliver when the engine reconnects."* The event sits on the ring
and is delivered when an engine comes back and drains it. Subscribers that are
connected at that later moment receive it then; the publish is not replayed to
clients that were subscribed at the original publish time and have since gone
away.

Treat `queued` as a signal to check engine health before you interpret a silent
client as a publishing bug. If you see `queued` for a publish that you expected
to be live, the event is not lost — the engine link is down.

## Publishing from the console instead of signing

For one-off tests, the Event Creator is the shorter path: it publishes through
the control plane with your org credentials, so there is no `auth_key`,
`body_md5` or HMAC to build, and `private-` / `presence-` channels work without
extra setup. Use the signed REST endpoint when the publisher is your own
backend, and reach for the **Equivalent cURL** card to get a copy-paste
starting point that already mirrors the channel, event and payload you typed
into the form.

## Related

- [Authentication](/concepts/authentication) — where app keys and secrets sit relative to client tokens
- [Telemetry](/platform/telemetry) — the metric and trace streams that cover publishes
