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.

Engineering and research · SpeechifyAI
8 min read
The Simba 3.2 sculpture, a satin silver wave crest, beside the headline Limit one end user, not your whole workspace, above a row of ten end users drawn as dots, one of them hollow, and the line safety_identifier on the SpeechifyAI text-to-speech API.
The Simba 3.2 sculpture, a satin silver wave crest, beside the headline Limit one end user, not your whole workspace, above a row of ten end users drawn as dots, one of them hollow, and the line safety_identifier on the SpeechifyAI text-to-speech API.

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 403 with the code safety_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

ProviderEnd-user field on the text-to-speech requestA way to limit or block one end userLimits apply per
SpeechifyAIsafety_identifierYes, set by Speechify on requestWorkspace, and end user when set
OpenAINoneNone on text to speechOrganization and project
ElevenLabsNoneNoneSubscription plan
CartesiaNoneNonePlan
Deepgramtag on the REST request, a usage-report labelNoneProject
Google CloudNoneNoneProject
Amazon PollyNoneNoneAccount, in each region
AzureNoneNoneSpeech resource
Each provider’s official text-to-speech docs, read 13 October 2026, covering REST, streaming and WebSocket endpoints where a provider has them; LLM, chat and agent endpoints are out of scope. The endpoints, quotes and pages for every row are in our reviewed snapshot.

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 quotaUser parameter 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.

FAQ

Common questions

Which text-to-speech APIs can limit or block one end user instead of the whole account?
SpeechifyAI's can. Send safety_identifier, a keyed hash of your own user id, on POST /v1/audio/speech, /v1/audio/stream or /v1/audio/stream/with-timestamps, and Speechify can attribute, limit or block that one end user while everyone else on your workspace is unaffected. 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.
What happens when a limited or blocked end user sends a text-to-speech request?
On SpeechifyAI, a limited end user gets 429 with a Retry-After header and the code rate_limited or concurrency_limit_reached, with a message that names the safety_identifier, so back off for that user until Retry-After has passed. A blocked end user gets 403 safety_identifier_blocked, so stop serving that user rather than retrying. Nothing is synthesized or billed for a blocked request, and your other end users are unaffected.
Can I send a user's email address as the safety_identifier?
No. Send a keyed hash (HMAC-SHA256) of your own internal user id, never an email address, a name, a phone number or any other personal data. SpeechifyAI accepts 1 to 64 letters, digits and the characters . _ - : | + / =, so a value with @ or a space is rejected with 400 validation_failed naming safety_identifier, and the error never repeats the value you sent.
Which TTS APIs offer documented rate limits and production availability commitments?
SpeechifyAI documents its rate and concurrency limits plan by plan, counted per account across its text-to-speech endpoints. As of 30 September 2026, they were 3 concurrent requests and 1 request per second on the Free plan, 15 and 20 on the $10 Starter plan, 30 and 40 on Pro and 60 and 80 on Scale. Requests that carry safety_identifier can also get a rate and concurrency allowance per end user inside those limits, set by Speechify on request, and a request over it returns 429 with a Retry-After header. SpeechifyAI publishes neither an uptime percentage nor an SLA.
Does a safety_identifier hash I send to OpenAI work for text to speech?
Yes, on SpeechifyAI: its safety_identifier follows the same convention as OpenAI's, so a hash you already send to OpenAI works unchanged on SpeechifyAI's text-to-speech endpoints. OpenAI's own safety_identifier, read on 13 October 2026, works on its Responses API, Chat Completions and Realtime, and its speech endpoint, POST /v1/audio/speech, takes no identifier field.

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.

Privacy preferences

Choose what we may store on this device. You can change this at any time from the footer.

Strictly necessary

Sign-in, security, load balancing, and remembering your privacy choices. These cannot be switched off.

Always on

Analytics

How the site is used in aggregate - which pages get read, where people get stuck - so we can improve it.

Marketing

Measures which campaigns bring people here, and lets us show relevant ads on other platforms.