Admin API
The extension registers eighteen routes under /api/_action/nice-translate/. Inside the extension they have one consumer, the Administration module. Everything that the module does is available to your own tooling through the same routes.
WARNING
Treat these routes as internal. The payloads and the response shapes can change between minor versions, without a deprecation cycle, and no compatibility promise covers them. Pin the extension version if you build against them.
Authentication
Each route declares the Admin API route scope (PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]). The routes are therefore available through the Admin API of Shopware only, and only with a valid Admin API access token. There is no Store API surface and no separate authentication scheme. Get a token through the standard Admin API OAuth endpoint of Shopware (POST /api/oauth/token), and send it as Authorization: Bearer <token>.
Permissions
Each route declares its necessary privilege through PlatformRequest::ATTRIBUTE_ACL. Shopware rejects a token whose integration or user does not have the privilege, before the controller runs. Permissions (ACL) describes the two roles.
The privilege does not always follow the HTTP verb, therefore read it for each route. POST /estimate only computes an estimate and still needs nice_translate.editor, while GET|POST /provider/{providerId}/models needs nice_translate.viewer for both verbs. For that reason, each route table on this page has a privilege column.
| Privilege | Routes |
|---|---|
nice_translate.viewer | GET /entities, GET /coverage, GET /providers, GET|POST /provider/{providerId}/models, GET /usage, GET /subscription, GET /glossary/export |
nice_translate.editor | POST /estimate, POST /job, POST /job/{jobId}/cancel, POST /job/{jobId}/retry, POST /job/{jobId}/revert, POST /provider/{providerId}/test, POST /preview, POST /subscription/refresh, POST /glossary/import, POST /history/revert, POST /history/reapply |
A worked example
This example estimates a run before it creates the job. It uses the same two calls as the wizard:
curl -sS -X POST "https://shop.example/api/_action/nice-translate/estimate" -H "Authorization: Bearer $SW_TOKEN" -H "Content-Type: application/json" -d '{"config":{"entities":[{"name":"product","scope":"missing"}],"targetLanguageIds":["a1b2c3d4e5f60718293a4b5c6d7e8f90"],"providerId":"deepl","options":{"mode":"missing"}}}'{
"items": 128,
"characters": 41230,
"exact": true,
"cost": { "amount": 1.0308, "currency": "USD" },
"credits": null,
"serviceTier": null,
"multiplier": null,
"quota": null,
"warnings": []
}If the estimate is correct, create the job with the identical config object:
curl -sS -X POST "https://shop.example/api/_action/nice-translate/job" -H "Authorization: Bearer $SW_TOKEN" -H "Content-Type: application/json" -d '{"config":{"entities":[{"name":"product","scope":"missing"}],"targetLanguageIds":["a1b2c3d4e5f60718293a4b5c6d7e8f90"],"providerId":"deepl","options":{"mode":"missing"}}}'{ "jobId": "018f2c1a8f7c73f2b0a4a1b7d1e5c904" }Your queue workers then process the job. Poll its status through the DAL entity nice_translate_job (see Entities through the DAL API).
The JobConfig object
POST /estimate and POST /job both take one config object, and JobConfig::fromArray() normalises it. Unknown keys pass through unchanged. The documented default replaces an invalid value.
{
"entities": [{ "name": "product", "scope": "all|missing|selection", "ids": null }],
"sourceLanguageId": null,
"targetLanguageIds": ["<uuid>"],
"providerId": "deepl",
"options": {
"mode": "missing",
"protectManualEdits": true,
"skipUnchanged": true,
"fields": null,
"includeCustomFields": true,
"tone": "default",
"customPrompt": null,
"glossary": true,
"seoTruncate": true,
"serviceTier": "balanced"
}
}| Field | Normalisation |
|---|---|
entities[].name | A bare string entity becomes {"name": …} |
entities[].scope | all, missing or selection. An invalid value becomes selection when ids are present, and all otherwise |
entities[].ids | The normaliser drops the non-string entries and the blank entries, and deduplicates the list. An empty list becomes null. With scope: "selection", each entry must be a valid UUID |
sourceLanguageId | A blank string becomes null, and the extension then resolves the source for each target language |
targetLanguageIds | The normaliser drops the blank entries and deduplicates the list |
options.mode | all, otherwise missing |
options.tone | default, formal or informal. Each other value becomes default |
options.serviceTier | speed, balanced or quality. Each other value becomes balanced |
options.customPrompt | Trimmed. An empty value becomes null |
options.fields | {"<entityName>": ["<property>", …]}. An empty result becomes null |
options.protectManualEdits, skipUnchanged, includeCustomFields, glossary, seoTruncate | Parsed as booleans |
serviceTier reaches the provider on the managed route only. Translation wizard and Safety mechanisms describe the effect of the other options on the run.
Jobs
| Method | Path | Privilege |
|---|---|---|
POST | /api/_action/nice-translate/estimate | nice_translate.editor |
POST | /api/_action/nice-translate/job | nice_translate.editor |
POST | /api/_action/nice-translate/job/{jobId}/cancel | nice_translate.editor |
POST | /api/_action/nice-translate/job/{jobId}/retry | nice_translate.editor |
POST | /api/_action/nice-translate/job/{jobId}/revert | nice_translate.editor |
POST /estimate
Request: {"config": <JobConfig>}. Response fields:
| Field | Type | Meaning |
|---|---|---|
items | int | The records that the run would process |
characters | int | The characters that the run would send |
exact | bool | false when the extension could only approximate a minimum of one entity |
cost | {amount, currency} | The provider estimate, rounded to four decimals |
credits | int | null | Not null for the managed provider only |
serviceTier | string | null | Not null for the managed provider only |
multiplier | int | null | The credit multiplier of the resolved tier |
quota | object | null | The managed quota snapshot |
warnings | list | {code, params, message} entries |
The estimate is advisory. The placeholder protection and the target-state gates operate later, and they can change the submitted text and the billable count. A provider whose own estimate throws contributes 0.0 USD and does not fail the call.
The endpoint emits exactly these eleven warning codes: unknown_provider, provider_not_configured, target_language_missing, source_equals_target, locale_unmappable, google_english_collapse, entity_unsupported, estimate_failed, snippet_source_set_missing, snippet_target_set_missing, managed_quota_exceeded. Cost & usage lists the message text of each code and its meaning for a merchant.
Errors: 400 {"message": "Missing \"config\" object in the request body."}, or 400 with the validation message when the config is invalid.
POST /job
Request: {"config": <JobConfig>}. Response: {"jobId": "<hex uuid>"}.
Errors: 400 for a missing config, and 400 with the validation message otherwise.
INFO
If the queue dispatch fails, the call still returns 200 with the job id. The committed queued row is the durable start marker, and the scheduled recovery relays it. See Architecture.
POST /job/{jobId}/cancel
No request body. The controller lower-cases {jobId} and validates it as a UUID. Response: {"status": "cancelled"}, the status that the controller read again after the attempt. Only a queued job and a running job change their status.
Errors: 400 {"message": "Invalid job id."}, 404 {"message": "Translation job not found."}.
POST /job/{jobId}/retry
Response: {"retried": <int>}. The route takes no body.
Only a completed job and a completed_with_errors job can retry. Each other job returns 400 {"message": "Only completed jobs can retry failed items."}. The retry excludes the error rows of the records that the extension translated in part. The content that the extension already wrote stays unchanged.
POST /job/{jobId}/revert
No body. Response: {"queued": true, "items": <int>}. A revert also dispatches a RevertJobMessage.
Errors: 400 for an invalid id; 404 for an unknown job; 400 {"message": "The job is still queued or running and cannot be reverted yet."}; 400 {"message": "The job has no revertible history rows."} when the job has no history row in the state applied.
Providers
| Method | Path | Privilege |
|---|---|---|
GET | /api/_action/nice-translate/providers | nice_translate.viewer |
GET, POST | /api/_action/nice-translate/provider/{providerId}/models | nice_translate.viewer |
POST | /api/_action/nice-translate/provider/{providerId}/test | nice_translate.editor |
POST | /api/_action/nice-translate/preview | nice_translate.editor |
GET /providers
No parameters. The route returns each service with the tag nice_translate.provider, which includes your own services:
{
"providers": [
{
"id": "deepl",
"label": "DeepL",
"configured": true,
"models": [],
"supports": { "html": true, "formality": true, "tone": false, "glossary": false },
"viaSubscription": false
}
]
}models is the curated list of the provider, and it is empty for a pure machine-translation provider. Each entry has id, name, tier, pricing and source. viaSubscription is true for the provider id managed only.
GET | POST /provider/{providerId}/models
Optional POST body: {"apiKey": "…"}, for a key that somebody entered in the Administration but did not save. The controller normalises a blank value or a whitespace-only value to null.
When the provider implements SupportsLiveModelsInterface and the request passed a key or the provider is already configured, the controller attempts a live listing. Each failure falls back to the curated list silently.
Response: {"models": [...], "source": "live"} or {"models": [...], "source": "curated"}.
Errors: 404 {"message": "Unknown translation provider \"x\"."}.
POST /provider/{providerId}/test
Body: {"apiKey": "…"} (optional; the controller falls back to the saved key). The response is always 200:
{ "valid": true, "message": "API key is valid.", "quota": { "used": 0, "limit": null } }quota is null when the provider reports none, and quota.limit is null for an unlimited plan. The controller converts a ProviderException to {"valid": false, "message": "<user-safe message>", "quota": null}. It never echoes the key back.
POST /preview
The route translates one sample text. It always builds the request with format: "text" and no glossary terms. No Administration screen calls this route. It exists for API clients.
{
"providerId": "openai",
"text": "Sample sentence.",
"sourceLanguageId": "<uuid>",
"targetLanguageId": "<uuid>",
"tone": "formal",
"customPrompt": null
}providerId, a non-blank text, sourceLanguageId and targetLanguageId are necessary. tone accepts formal or informal only, and the controller treats each other value as unset.
Response: {"translation": "…", "characters": <int>, "cost": {"amount": <float>, "currency": "USD"}}.
Errors: 400 {"message": "The fields \"providerId\", \"text\", \"sourceLanguageId\" and \"targetLanguageId\" are required."}; 400 {"message": "Invalid language id."}; 400 {"message": "The source or target language could not be resolved to a locale."}; 400 with the user-safe message of the provider when the provider throws; 404 for an unknown provider.
Catalogue
| Method | Path | Privilege |
|---|---|---|
GET | /api/_action/nice-translate/entities | nice_translate.viewer |
GET | /api/_action/nice-translate/coverage | nice_translate.viewer |
GET /entities
No parameters. The route lists the translatable content types, with their labels, their row totals and their resolved field specs:
{
"entities": [
{
"name": "product",
"label": "Products",
"total": 1234,
"fields": [{ "property": "name", "type": "text", "maxLength": 255 }]
}
]
}fields[].type is text, html, custom_fields, string_list or structure. The route omits the entities that the installation does not have. The list always ends with the snippet pseudo-entity. Supported content describes which content types appear and why.
GET /coverage
| Query parameter | Meaning | Default when omitted |
|---|---|---|
languageIds | Comma-separated language UUIDs | Each non-system language, sorted by name |
entities | Comma-separated entity names | product,category,cms_page,snippet |
{
"rows": [
{ "entity": "product", "languageId": "<hex>", "total": 100, "translated": 40, "percent": 40.0 }
]
}Errors: 400 {"message": "Invalid language id \"…\"."}; 400 {"message": "Unsupported entities: ….."}.
The coverage counts the rows with their own translation of the primary text column of the entity, which makes it useful to find drift. The resolution stops at the row: a row counts as translated even when individual fields of it are still empty. See Coverage report.
Usage and subscription
| Method | Path | Privilege |
|---|---|---|
GET | /api/_action/nice-translate/usage | nice_translate.viewer |
GET | /api/_action/nice-translate/subscription | nice_translate.viewer |
POST | /api/_action/nice-translate/subscription/refresh | nice_translate.editor |
GET /usage
Query parameter months (numeric, clamped to the range 1 to 36, default 6). The route returns the newest period first.
{
"months": [
{
"period": "2026-07",
"providers": {
"deepl": {
"characters": 41230,
"inputTokens": 0,
"outputTokens": 0,
"requests": 12,
"cost": { "amount": 1.0308, "currency": "USD" }
}
}
}
]
}These amounts are the records of the extension. The invoice of your provider stays the authoritative figure. See Cost & usage.
GET /subscription
No parameters.
{
"available": false,
"active": true,
"state": "active",
"plan": "scale",
"identifier": "NiceTranslateAllInOneScale",
"plans": [
{ "plan": "enterprise", "identifier": "NiceTranslateAllInOneEnterprise", "credits": 96000000 },
{ "plan": "scale", "identifier": "NiceTranslateAllInOneScale", "credits": 32000000 },
{ "plan": "growth", "identifier": "NiceTranslateAllInOneGrowth", "credits": 12000000 },
{ "plan": "starter", "identifier": "NiceTranslateAllInOneStarter", "credits": 4000000 }
],
"quota": null,
"reachable": true
}state is active, available, expired or disconnected. available is true exactly when state === "available". modernice All-in-One describes the meaning of the states for a merchant.
POST /subscription/refresh
No body. The route triggers the in-app-purchase updater of Shopware, then derives the same status structure as GET /subscription. The Administration polls this route after a checkout. Without it, a new purchase would appear only with the daily scheduled refresh.
Errors:
{
"errors": [
{
"code": "MO_TRANSLATE__STORE_DISCONNECTED",
"detail": "Connect this administration user to the Shopware Store before refreshing purchases."
}
]
}The route returns this with 412 when the administration user has no Store authentication, and
{
"errors": [
{
"code": "MO_TRANSLATE__SUBSCRIPTION_REFRESH_FAILED",
"detail": "The Shopware in-app purchase state could not be refreshed."
}
]
}with 502 when the updater throws.
Glossary
| Method | Path | Privilege |
|---|---|---|
POST | /api/_action/nice-translate/glossary/import | nice_translate.editor |
GET | /api/_action/nice-translate/glossary/export | nice_translate.viewer |
POST /glossary/import
Body: {"csv": "…", "delimiter": ";"}. delimiter is optional and must be exactly one character. The controller removes a UTF-8 BOM at the start of csv.
The header must start with term, mode and caseSensitive (compared without case sensitivity), then one column for each locale code:
term;mode;caseSensitive;de-DE;fr-FR
modernice;keep;1;;
Delivery time;replace;0;Lieferzeit;Délai de livraisonRow handling: the controller skips an empty term; an empty mode becomes keep; a mode outside keep and replace skips the row; caseSensitive is true for 1, true or yes. The controller deduplicates the rows by the lower-cased term, updates the existing terms and creates the new terms as active.
Response: {"imported": <int>, "created": <int>, "updated": <int>, "skipped": <int>}.
Errors: 400 {"message": "Missing \"csv\" content in the request body."}; 400 {"message": "The delimiter must be a single character."}; 400 {"message": "The CSV content is empty."}; 400 {"message": "Invalid CSV header. Expected columns: term;mode;caseSensitive;<localeCode>;..."}; 400 {"message": "Unknown locale code(s) in CSV header: ….."}.
GET /glossary/export
No parameters. The route returns text/csv; charset=utf-8 with a UTF-8 BOM and Content-Disposition: attachment; filename=nice-translate-glossary.csv. The delimiter is ;. The columns are term;mode;caseSensitive, then one column for each distinct locale code, sorted without case sensitivity. The route emits caseSensitive as 1 or 0. The export always contains the full glossary, and you can import it again to update the matching terms.
History
| Method | Path | Privilege |
|---|---|---|
POST | /api/_action/nice-translate/history/revert | nice_translate.editor |
POST | /api/_action/nice-translate/history/reapply | nice_translate.editor |
Both routes take {"ids": ["<uuid>", …]} and return the same shape:
{ "done": 3, "skipped": [{ "id": "<uuid>", "reason": "…" }] }Both directions compare the current direct target value against the expected history value before they write. The routes report a record that somebody modified in the meantime, or that is in the wrong state, in skipped, and they leave it unchanged. See Translation history.
Errors: 400 {"message": "Missing \"ids\" array in the request body."}; 400 {"message": "A maximum of 200 ids can be processed per request."}.
Entities through the DAL API
The job listing and detail, the job errors, the glossary CRUD, the usage rows and the history rows have no custom routes. They are registered DAL entities, and they use the generated repository endpoints of Shopware:
| Entity | Read privilege | Write privileges |
|---|---|---|
nice_translate_job | nice_translate_job:read | nice_translate_job:create, :update, :delete |
nice_translate_job_error | nice_translate_job_error:read | — |
nice_translate_glossary | nice_translate_glossary:read | nice_translate_glossary:create, :update, :delete |
nice_translate_usage | nice_translate_usage:read | — |
nice_translate_history | nice_translate_history:read | nice_translate_history:update |
Each field of these entities is declared ApiAware(AdminApiSource::class). They are therefore readable through the Admin API only, and they never appear in the Store API. The other six tables of the extension are DBAL-only records, without a definition, without a repository and without an API resource. See Architecture.