# One API Key, Many Tenants: Isolating DeepL Translations in a Multi-Tenant SaaS

> How to use a single DeepL API key across many tenants without glossaries, style rules, or cached translations leaking between customers. Here's the approach I use for Rasepi.

2026-04-18 · Tim Cadenbach · https://www.tcdev.de/blog/one-api-key-many-tenants-deepl-isolation/

---

Every time I explain the translation architecture behind Rasepi to another developer, I get the same question: "Wait — all your tenants share one DeepL API key? How do you keep their glossaries and style rules from leaking into each other?"

It's a fair question. And the answer involves more design work than you'd expect.

I wrote about the [full translation pipeline](https://www.tcdev.de/en/blog/inside-the-translation-engine-glossaries-style-rules-and-smart-retranslation/) in a previous post — the block-level hashing, the orchestrator, the whole flow from document save to translated output. This post zooms into a specific sub-problem: how you take a third-party API that has no concept of tenants and build proper tenant isolation on top of it.

## DeepL doesn't know about your customers

DeepL's API authenticates with a single API key. Everything created under that key — glossaries, style rule lists, translation history — belongs to the same account. There's no concept of "this glossary belongs to Customer A" on DeepL's side.

When you call `GET /v2/glossaries`, you get *all* glossaries from *all* tenants. When you create a style rule list, it lives in the same namespace as everything else. The API is flat.

For a self-hosted product where every customer runs their own instance with their own DeepL key, that's fine. For a multi-tenant SaaS where you manage the infrastructure? You need an isolation layer, and you need to build it yourself.

## The database is the source of truth

My core design decision here: **the database owns all glossary content and style rule configuration. DeepL is a runtime execution target, nothing more.**

Every `TenantGlossary` and `TenantStyleRuleList` entity implements `ITenantScoped`, which means EF Core global query filters automatically scope all reads to the current tenant. A query for glossaries in Tenant A's request context will never return Tenant B's entries. This is the same isolation pattern I use everywhere in Rasepi, enforced at the ORM level — I didn't build anything special for translations specifically.

Here's what makes this interesting. When a tenant edits a glossary term, I do not immediately call DeepL. I update the database row and set `IsDirty = true`. That's it. The actual DeepL glossary gets created (or recreated) lazily, right before the next translation needs it.

```csharp
public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
    string sourceLanguage, string targetLanguage)
{
    var glossary = await _db.TenantGlossaries
        .Include(g => g.Entries)
        .FirstOrDefaultAsync(g =>
            g.SourceLanguage == sourceLanguage &&
            g.TargetLanguage == targetLanguage);

    if (glossary?.Entries.Count == 0) return null;

    if (!glossary.IsDirty && glossary.DeepLGlossaryId is not null)
        return glossary.DeepLGlossaryId;

    // Dirty: delete old, create new
    if (glossary.DeepLGlossaryId is not null)
        await _deepL.DeleteGlossaryAsync(glossary.DeepLGlossaryId);

    var entries = glossary.Entries
        .ToDictionary(e => e.SourceTerm, e => e.TargetTerm);

    var created = await _deepL.CreateGlossaryAsync(
        $"tenant-{glossary.Id}",
        glossary.SourceLanguage,
        glossary.TargetLanguage,
        entries);

    glossary.DeepLGlossaryId = created.GlossaryId;
    glossary.IsDirty = false;
    glossary.LastSyncedAt = DateTime.UtcNow;
    await _db.SaveChangesAsync();

    return glossary.DeepLGlossaryId;
}
```

The query filter on `TenantGlossaries` does the isolation. The `IsDirty` flag does the lazy sync. The naming convention (`tenant-{glossary.Id}`) exists only for debugging in the DeepL dashboard — it has no functional purpose in the code.

Why lazy? Because [DeepL v2 glossaries are immutable](https://developers.deepl.com/docs/api-reference/glossaries). You cannot edit them. Any change means delete and recreate. If a team imports a CSV with 200 terms and then fixes a typo in one entry, I don't want to delete and recreate the DeepL glossary twice. I just set `IsDirty` both times, and the single recreate happens when the next translation runs. Batching for free.

## Style rules: same pattern, different API

[DeepL's style rules](https://developers.deepl.com/docs/api-reference/translate/openapi-spec-for-text-translation) are newer (v3 API) and actually mutable, which is nicer. You can update configured rules in place with `PUT /v3/style_rules/{style_id}/configured_rules`, and custom instructions can be individually added or removed.

I still use the same `IsDirty` pattern though, mostly for consistency. A `TenantStyleRuleList` has a `DeepLStyleId` that maps to DeepL's runtime identifier, plus `ConfiguredRulesJson` for the formatting rules and a collection of `TenantCustomInstruction` entries for free-text translation directives.

The real power is in those custom instructions. Each one is a plain-language directive, up to 300 characters, that shapes how DeepL translates. Some real examples I've seen work well:

- _"Always use 'Sie' form, never 'du'"_ — for formal German contexts
- _"Translate 'deployment' as 'Bereitstellung', never 'Deployment'"_ — context-dependent terms that go beyond simple glossary mappings
- _"Use British English spelling (colour, organisation, licence)"_ — when translating between English variants
- _"Put currency symbols after the numeric amount"_ — European formatting conventions

Each tenant can configure completely different instructions per target language, all behind the same API key. The isolation comes from the fact that every translation call includes only the `glossary_id` and `style_id` belonging to the requesting tenant. Other tenants' DeepL resources are never referenced — they're not even queried.

## The translation call: everything composes

When the orchestrator translates a block, it assembles all tenant-specific settings into a single request:

```csharp
var glossaryId = await _glossaryService
    .GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang);
var styleId = await _styleRuleService
    .GetOrSyncStyleIdAsync(targetLang);
var formality = langConfig.Formality ?? "default";

var options = new TranslationOptions
{
    GlossaryId = glossaryId,
    StyleId = styleId,
    Formality = formality,
    Context = documentContext,
    ModelType = styleId != null ? "quality_optimized" : null
};
```

Every parameter here is tenant-scoped. The `glossaryId` was resolved through a tenant-filtered query. The `styleId` was resolved the same way. The `formality` comes from `TenantLanguageConfig`, also tenant-scoped. Even the `context` — surrounding paragraphs sent to improve translation quality, which DeepL doesn't bill for — comes from the tenant's own document.

One thing worth noting: when `style_id` is set, DeepL automatically uses their `quality_optimized` model. You can't combine style rules with `latency_optimized`. That's a DeepL constraint, but honestly a reasonable trade-off. If you're investing in custom style rules, you probably want the best quality output anyway.

## Block-level caching: your database as translation memory

I don't call DeepL for blocks that haven't changed. The caching mechanism is the `TranslationBlock` table itself.

Every source `EntryBlock` has a `ContentHash` — a SHA256 of its semantic content, with metadata attributes like `blockId` and `deleted` stripped out. Every `TranslationBlock` stores the `SourceContentHash` that was current when the translation was made. When the source block changes, its hash changes. The orchestrator compares hashes and only queues blocks with mismatches.

The decision tree for each block:

1. **Hash matches, translation exists** → skip (cached, up-to-date)
2. **Hash changed, machine-translated, not locked** → retranslate automatically
3. **Hash changed, human-edited or locked** → mark as Stale, do not overwrite

That third case matters a lot. If a translator manually refined a paragraph, I don't want to blow it away just because the English source changed. Flag it as stale so the team knows it needs review, but leave the translated text intact.

The practical result: editing one paragraph in a 30-paragraph document triggers exactly one DeepL API call (one batch, one block). The other 29 paragraphs across all languages are already cached. They don't cost anything.

## Why not give each tenant their own key?

I considered it. Give each tenant their own DeepL API key, eliminate the isolation problem entirely.

Three reasons I didn't go that route:

1. **Billing complexity.** Every tenant would need their own DeepL subscription or a way to provision sub-accounts. DeepL doesn't offer multi-tenant key management natively. Managing that onboarding flow is more overhead than building an isolation layer.
2. **Cost efficiency.** Shared infrastructure means shared volume. Aggregate usage gets better pricing than dozens of individual small accounts.
3. **Operational simplicity.** One key to rotate, one quota to monitor, one integration to maintain. That's genuinely valuable.

The trade-off is that you need the isolation layer I described. But if you already have tenant-scoped EF Core queries for everything else in your system — which you should — adding it to glossaries and style rules is straightforward. You're applying an existing pattern, not inventing a new one.

## What actually isolates what

To summarize the guarantees I rely on:

- **Glossary entries** are stored in `TenantGlossary` (implements `ITenantScoped`), filtered by EF Core global query filters. DeepL glossary IDs are opaque references that only get resolved within tenant context.
- **Style rules and custom instructions** follow the same pattern through `TenantStyleRuleList`.
- **Translated content** lives in `TranslationBlock`, scoped via its parent `Entry` → `Hub` chain, which is also tenant-scoped.
- **The `SaveChanges` guard** sets `TenantId` automatically on new entities and throws on cross-tenant writes.
- **No `IgnoreQueryFilters()`** in production code.

> DeepL sees resource IDs. My application sees tenant-scoped entities. The mapping between them never crosses tenant boundaries because the query that resolves the mapping is physically incapable of returning another tenant's data.

If you're building a multi-tenant SaaS on top of third-party APIs that weren't designed for multi-tenancy — and there are a lot of them — this approach works well. Treat the external API as a stateless execution engine. Keep all configuration in your own tenant-scoped database. Sync lazily. And never trust external resource listings for isolation, because those listings are flat.
