Cutting off an abusive client from a channel immediately
Revoke the token, then disconnect the connection, in that order. Plus the moderation loop that makes the decision before a human has to.
When someone is flooding a channel, Infrai gives you two calls and the order matters: POST /v1/realtime/token/revoke stops the credential from being used again, and POST /v1/realtime/user/disconnect drops the connection they already have. Disconnect alone achieves nothing — the client reconnects with the same still-valid token in the time it takes to notice.
Revoke, then disconnect. Then decide whether the channel itself needs to go.
The two calls
curl -sS -X POST "https://api.infrai.cc/v1/realtime/token/revoke" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"token_or_jti": "jti_4kQ9mVzR1sXbNt"}'
{
"ok": true,
"data": { "revoked": true }
}
curl -sS -X POST "https://api.infrai.cc/v1/realtime/user/disconnect" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"client_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "channel": "room:lobby"}'
{
"ok": true,
"data": { "disconnected": true }
}
channel is optional on disconnect. Omit it to drop the client everywhere, which is what you want for account-level abuse rather than a single bad room.
This is why storing the jti when you issue a token isn’t optional. Without it you can disconnect a client and watch it come straight back.
The containment routine
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
# Whatever store you keep: client_id -> the jtis you issued for it.
ISSUED: dict[str, list[str]] = {}
def eject(client_id: str, channel: str | None = None, reason: str = "flooding") -> dict:
"""Revoke every token this client holds, then drop its connection, then record
why. Reversing the first two steps lets the client reconnect between them."""
revoked = 0
for jti in ISSUED.get(client_id, []):
resp = SESSION.post(f"{API}/v1/realtime/token/revoke",
json={"token_or_jti": jti}, timeout=15)
if resp.ok and resp.json().get("data", {}).get("revoked"):
revoked += 1
ISSUED[client_id] = []
body = {"client_id": client_id}
if channel:
body["channel"] = channel
dropped = SESSION.post(f"{API}/v1/realtime/user/disconnect", json=body, timeout=15)
SESSION.post(
f"{API}/v1/errors/capture",
json={"title": "realtime client ejected", "level": "warning",
"user_id": client_id, "tags": {"reason": reason, "channel": channel or "*"}},
timeout=15,
)
return {"tokens_revoked": revoked,
"disconnected": bool(dropped.ok and dropped.json().get("data", {}).get("disconnected"))}
if __name__ == "__main__":
print(eject(os.environ["CLIENT_ID"], channel="room:lobby"))
That third call is the part people skip and then regret. An ejection with no record is an ejection you can’t explain when the user emails support, and POST /v1/errors/capture on the same key gives you a searchable trail without adding a vendor.
Detect it before a human has to
Ejection is the easy half. Noticing is the part that needs design, and the two signals worth watching are both already available.
GET /v1/realtime/presence/get/{channel} gives you the roster and member count; a channel whose count spikes is either going viral or being joined by one script with many client ids. And your own publish path sees the rate directly — if clients publish through your API rather than holding publish capability themselves, you can count per client before anything reaches the channel.
That’s the strongest argument for not granting the publish capability to browsers at all. A client that can only subscribe cannot flood anything, so the abuse you’re containing here is limited to connection churn rather than message volume.
| Grant | What abuse becomes possible |
|---|---|
subscribe only | connection churn; no content |
subscribe + presence | roster noise on join/leave |
publish | message flooding, impersonating your server |
Account-scoped token (no channels) | attaching to any channel they can guess |
When the channel is the problem
Sometimes the right move isn’t per-client. A channel that’s become a target can go:
curl -sS -X DELETE "https://api.infrai.cc/v1/realtime/channel/delete/room:lobby" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "channel": "room:lobby", "deleted": true, "status": "deleted" }
}
Everyone loses the channel, which is a blunt instrument — but for a public room being used for something it shouldn’t, deleting and recreating under a name that isn’t in a script’s list is often the fastest containment available.
Check what exists first with GET /v1/realtime/channel/list, because deleting the wrong channel during an incident is an easy mistake to make at speed.
Limitations
There’s no ban list. Nothing stops the same person obtaining a new token from your own endpoint a second later, so the durable block belongs in your authorisation logic — the mint endpoint has to refuse them, and the platform can only enforce what you’ve already decided. Treat revoke and disconnect as the “stop it now” half of a mechanism whose “keep it stopped” half is yours.
There’s also no rate-limit-per-client control on the channel itself, and no moderation primitives — no content filtering, no automatic muting. If your product is a consumer chat where moderation is a feature, Ably and PubNub both ship more here, and a purpose-built moderation service is a better answer than assembling one from these calls.
What you don’t need is a second vendor for the response. The presence read that detected it, the revoke, the disconnect, the error record and the POST /v1/email/send that tells the room owner are all one credential and one line in GET /v1/account/usage. Token and channel management report billing_class: free in discovery; publishing is the billable part, live in GET /v1/discovery/realtime.publish (verified 2026-09-21), and those rates drift downward as vendor contracts improve.