Per-user rate limits for a text-to-speech API: limit or block one end user with safety_identifier
SpeechifyAI's text-to-speech API takes an optional safety_identifier, a keyed hash of your own user id, so one end user can be limited or blocked while the rest of your workspace keeps working. On 13 October 2026, seven other TTS APIs documented no way to do that.

SpeechifyAI’s text-to-speech API takes an optional safety_identifier on POST /v1/audio/speech, /v1/audio/stream and /v1/audio/stream/with-timestamps, and it names the end user of your own app behind each request.
Without it, the only lever against one abusive user is your whole workspace.
With it, we can attribute, limit or block that one user and leave everyone else alone, and leaving it out changes nothing.
On 13 October 2026, the text-to-speech docs of OpenAI, ElevenLabs, Cartesia, Deepgram, Google Cloud, Amazon Polly and Azure documented no way to limit or block one end user. Their limits apply to the whole account, project, plan or resource (snapshot).
Why a multi-user app needs it
Most apps already rate-limit per user in their own backend. Not because they expect trouble, but so that when one account misbehaves, through a bug, a runaway script or on purpose, the fix touches that account and nobody else.
A text-to-speech API sees none of that on its own. Every request your backend makes arrives on the same workspace, so to us your users look like one customer. If one of them trips a limit or a content screen, the only lever left would touch everyone you serve.
safety_identifier gives us the same per-user view your backend already has.
Send it when many of your own users share one Speechify workspace: a gateway or reseller, a consumer app, or any product where the person typing the text is not you.
What it does
- Attribution. The identifier is recorded with the request as metadata, not content. It is kept even when your workspace has zero data retention, because it is not the text you sent, and with zero data retention the text is still withheld as usual. When content screening refuses a request, the refusal is recorded against that end user, so repeat abuse by one person is visible as one person.
- Per-end-user limits.
A workspace can be given a rate and concurrency allowance per end user, inside its own API limits, and a request must fit both.
They are off unless set for your workspace, and they never apply to a request without a
safety_identifier. - Blocking one end user.
Every synthesis request that names a blocked end user returns
403with the codesafety_identifier_blocked, and nothing is synthesized or billed for it. - Everyone else carries on. A limit or a block on one end user leaves your other end users unaffected.
- No cost. The field is never billed, and it changes no workspace limit.
Your workspace’s per-end-user ceilings are on GET /v1/workspaces/current/entitlements as tts_end_user_requests_per_second and tts_end_user_concurrency, where 0 means none applies.
The workspace limits they sit inside are compared plan by plan in our TTS API concurrency and rate limits post.
Limits and blocks are set by Speechify, on request
Per-end-user limits and blocks are not self-serve: Speechify sets them for your workspace.
Contact us to have limits set, or with the request_id of a refusal you believe is a mistake.
Attribution needs no setup, so sending the identifier now means your requests are already attributed to their end users by the time you ask for a limit.
Send a keyed hash of your own user id
Derive the identifier from your own internal user id with a keyed hash, HMAC-SHA256, and send the same value for the same person on every request. A value that changes per session or per request cannot be attributed, limited or blocked. This is the example from the safety identifiers guide, in Python:
import hashlib
import hmac
import os
from speechify import Speechify
client = Speechify(token=os.environ["SPEECHIFY_API_KEY"])
secret = os.environ["SAFETY_IDENTIFIER_SECRET"].encode()
def safety_identifier(user_id: str) -> str:
return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
audio = client.audio.speech(
input="Hello from the Speechify API.",
voice_id="geffen_32",
model="simba-3.2",
audio_format="mp3",
safety_identifier=safety_identifier("user-8421"),
)
And in TypeScript:
import { createHmac } from "node:crypto";
import { SpeechifyClient } from "@speechify/api";
const client = new SpeechifyClient({ token: process.env.SPEECHIFY_API_KEY! });
function safetyIdentifier(userId: string): string {
return createHmac("sha256", process.env.SAFETY_IDENTIFIER_SECRET!).update(userId).digest("hex");
}
const audio = await client.audio.speech({
input: "Hello from the Speechify API.",
voice_id: "geffen_32",
model: "simba-3.2",
audio_format: "mp3",
safety_identifier: safetyIdentifier("user-8421"),
});
Never send an email address, a name, a phone number or any other personal data. The identifier should be stable for you and meaningless to anyone else, which is what the keyed hash gives you. A keyed hash is better than a plain one, because a plain hash of an email address can be reversed by hashing a list of known addresses. Keep the secret fixed, because rotating it gives every user a new identifier.
The value must be 1 to 64 characters of letters, digits and . _ - : | + / =, which fits a hex or base64 SHA-256, a UUID or an id such as user_123.
It refuses @ and spaces, so an email address or a name is rejected with 400 validation_failed naming safety_identifier rather than stored, and the error never repeats the value you sent.
Handle a limit or a block for that one user
A limited end user gets 429 with a Retry-After header and the same codes as the workspace limits, rate_limited or concurrency_limit_reached, with a message that names the safety_identifier.
Back off for that user alone until Retry-After has passed, and keep serving everyone else.
A 429 can also come from your workspace’s own limits, which all your users share; only an end user’s limit names the safety_identifier in its message.
A blocked end user gets 403 with the code safety_identifier_blocked.
Treat it as persistent: stop serving that user rather than retrying, and do not resend their requests without the identifier.
Your other users are unaffected, so nothing else in your app changes.
Here is that handling over plain HTTP in TypeScript, with the safetyIdentifier function from above:
const pausedUntil = new Map<string, number>();
const blocked = new Set<string>();
async function speak(userId: string, input: string) {
const id = safetyIdentifier(userId);
if (blocked.has(id) || (pausedUntil.get(id) ?? 0) > Date.now()) return null;
const response = await fetch("https://api.speechify.ai/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SPEECHIFY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input,
voice_id: "geffen_32",
model: "simba-3.2",
audio_format: "mp3",
safety_identifier: id,
}),
});
if (response.status === 429) {
const { error } = await response.json();
const seconds = Number(response.headers.get("Retry-After")) || 1;
if (!error.message.includes("safety_identifier")) {
throw new Error(`Workspace limit reached, retry in ${seconds}s`);
}
pausedUntil.set(id, Date.now() + seconds * 1000);
return null;
}
if (response.status === 403) {
const { error } = await response.json();
if (error.code === "safety_identifier_blocked") blocked.add(id);
return null;
}
if (!response.ok) throw new Error(`Speechify returned ${response.status}`);
return response.json();
}
In production, keep the pause and the block wherever your app already keeps per-user state, so every server sees them.
Already sending it to OpenAI
Our field follows the same convention as OpenAI’s safety_identifier, so a hash you already send there works here unchanged.
Read on 13 October 2026, OpenAI’s own safety_identifier works on its Responses API, Chat Completions and Realtime, while its speech endpoint, POST /v1/audio/speech, takes no identifier field (OpenAI’s safety checks guide, snapshot).
What the other TTS APIs document
| Provider | End-user field on the text-to-speech request | A way to limit or block one end user | Limits apply per |
|---|---|---|---|
| SpeechifyAI | safety_identifier | Yes, set by Speechify on request | Workspace, and end user when set |
| OpenAI | None | None on text to speech | Organization and project |
| ElevenLabs | None | None | Subscription plan |
| Cartesia | None | None | Plan |
| Deepgram | tag on the REST request, a usage-report label | None | Project |
| Google Cloud | None | None | Project |
| Amazon Polly | None | None | Account, in each region |
| Azure | None | None | Speech resource |
Three of them come closer than the table shows, read on 13 October 2026 (snapshot):
- OpenAI blocks by
safety_identifier, but not on speech: the field covers its Responses API, Chat Completions and Realtime, and its speech endpoint takes no identifier. - Deepgram’s REST speak request takes a
tag, which its docs describe as a label “for the purpose of identification during usage reporting”. A customer could fill it with an end-user id, but Deepgram’s rate limits apply per project with no per-tag limit, and its speak WebSocket takes no tag. - Google Cloud documents a platform-wide
quotaUserparameter for “charging per-user quotas”, but it is not on the text-to-speech reference, and every text-to-speech quota Google publishes is per project.
We also compare what text-to-speech APIs cost at volume and how their models score with blind listeners.
Send it from your first request
Add safety_identifier to the requests you already make, with the same keyed hash for the same person every time.
When you stream an LLM reply one HTTP request per sentence, send it on every one of those requests.
You can get an API key and send it from your first request, and talk to us when you want a limit set for each end user.
Common questions
Which text-to-speech APIs can limit or block one end user instead of the whole account?
What happens when a limited or blocked end user sends a text-to-speech request?
Can I send a user's email address as the safety_identifier?
Which TTS APIs offer documented rate limits and production availability commitments?
Does a safety_identifier hash I send to OpenAI work for text to speech?
This post is narrated by Harper on Simba 3.2 through our text to speech API. How the player works: add read-aloud to your docs and turn speech marks into captions.
Related reading
Benchmarks & pricingUpdated Oct 8, 2026
15concurrent requests on the Starter plan
Benchmarks & pricingUpdated Oct 8, 2026
TTS API concurrency and rate limits, October 2026: which plans run 20 concurrent streams
Benchmarks & pricingUpdated Sep 28, 2026
$91a month for 10M characters
Benchmarks & pricingUpdated Sep 28, 2026
TTS API pricing 2026: what 1M, 10M and 100M characters a month really cost
Build guidesUpdated Oct 12, 2026
49 msfrom a finished sentence to first sound
Build guidesUpdated Oct 12, 2026
WebSocket TTS streaming for voice agents: LLM tokens in, speech out, measured in October 2026
Benchmarks & pricingUpdated Oct 16, 2026
1,237Elo on Artificial Analysis
Benchmarks & pricingUpdated Oct 16, 2026
Best TTS APIs 2026: 10 models compared by Elo and price