> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.markup.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.markup.ai/_mcp/server.

# Migrating from Acrolinx

> Move an integration from the Acrolinx Terminology API v9 to Style Agent Terminology, call by call.

The Style Agent Terminology API reads and writes the same terminology you manage in the [console](https://console.markup.ai): entries, terms, languages, statuses, domains and fields. If you have scripts or integrations built on the Acrolinx Terminology API v9, such as a termbase sync, term contribution or nightly backups, this page shows how to change each call.

Entries are still exchanged as ACTIF 1.0 XML, and categories and fields are still referred to by name, so most integrations only change the host, the authentication and a few paths. New integrations can also use the JSON endpoints in this section, which address terms and categories by id.

## What changes

|                | Acrolinx Terminology API v9                                                                     | Style Agent Terminology API                                            |
| -------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Base URL       | `https://<your-host>/iq/services/v9/rest/terminology`                                           | `https://api.markup.ai/style-agent/terminology`                        |
| Authentication | An auth token, then a session from `requestSession`, sent on every call and released afterwards | An API key, sent as `Authorization: Bearer <API key>` on every request |
| Entry formats  | ACTIF XML, ACTIF JSON, MTF                                                                      | ACTIF 1.0 XML                                                          |
| Search results | JSON                                                                                            | JSON                                                                   |
| Publishing     | `deployTerminology`, synchronous trigger                                                        | `POST /publish`, a background job you poll                             |

Paths on the rest of this page are relative to the base URL.

## Authenticate

There are no sessions to request or release. [Create an API key](/admin/api-keys) in the console and send it on every request. The key belongs to your organization, so every call reads and writes that organization's terminology.

**`Before (v9)`**

```bash title="Before (v9)"
TOKEN=$(curl -s -X POST "https://<your-host>/iq/services/v3/rest/core/authToken" \
  -H "username: $USER_NAME" -H "encryptedOrPlaintextPassword: $PASSWORD")

SESSION=$(curl -s -X POST "https://<your-host>/iq/services/v3/rest/core/requestSession" \
  -H "authToken: $TOKEN" -H "Content-Type: application/json" \
  -d '{"sessionType": "TERMINOLOGY", "clientSignature": "<signature>", "clientInfo": {"name": "sync"}}')

curl "https://<your-host>/iq/services/v9/rest/terminology/schema" \
  -H "Authorization: session $SESSION"

curl -X DELETE "https://<your-host>/iq/services/v3/rest/core/releaseSession/$SESSION"
```

**`Now (cURL)`**

```bash title="Now (cURL)"
API_KEY="YOUR_API_KEY"
BASE="https://api.markup.ai/style-agent/terminology"

curl "$BASE/schema" -H "Authorization: Bearer $API_KEY"
```

**`Now (Python)`**

```python title="Now (Python)"
import httpx

api = httpx.Client(
    base_url="https://api.markup.ai/style-agent/terminology",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=120,
)
```

The Python samples below use this `api` client.

## Call by call

| Task                        | v9                                                          | Now                                                                        |
| --------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| Read the schema             | `GET /schema`                                               | [`GET /schema`](#read-the-schema)                                          |
| Search terms                | `POST /searchTerms`, `GET /searchTerms/{sessionId}/{query}` | [`GET /terms`](#search-terms)                                              |
| Search entries              | `POST /searchEntries`                                       | [`GET /terms`](#search-terms) with `domain`                                |
| Get entries                 | `GET /entries/{entryIDorUUIDs}`                             | [`GET /entries/{entry}`](#get-entries)                                     |
| Create or update entries    | `POST /entries`                                             | [`POST /entries`](#create-or-update-entries)                               |
| Delete an entry             | `DELETE /entries/{entryUUID}`                               | [`DELETE /entries/{entry}`](#delete-an-entry)                              |
| Propose a term              | `POST /candidates`                                          | [`POST /terms/contribute`](#propose-a-term)                                |
| Deploy                      | `PUT /deployTerminology`                                    | [`POST /publish`](#publish), then `GET /jobs/{job_id}`                     |
| Back up                     | `GET /dump/{sessionId}`, `POST /dump/request/{sessionId}`   | [`GET /export/xml-dump`](#back-up-and-restore)                             |
| Restore                     | `PUT /dump/restore`                                         | [`POST /import/xml-dump`](#back-up-and-restore), then `GET /jobs/{job_id}` |
| Export ACTIF                | `POST /exportTerminology/{sessionId}`                       | [`GET /export/actif`](#back-up-and-restore)                                |
| Upload or download an image | `POST`, `GET /media/{fileName}`                             | `POST /images`, `GET /images/{filename}`                                   |

### Read the schema

`GET /schema` returns an ACTIF document with only the `<schema>` element: your languages, statuses, domains, other category types and fields, by name. These are the names an entry you post can use.

**`Before (v9)`**

```bash title="Before (v9)"
curl "https://<your-host>/iq/services/v9/rest/terminology/schema" \
  -H "Authorization: session $SESSION" \
  -H "Accept: application/vnd.acrolinx.actif+xml"
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl "$BASE/schema" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Accept: application/vnd.acrolinx.actif+xml"
```

**`Now (Python)`**

```python title="Now (Python)"
import xml.etree.ElementTree as ET

response = api.get("/schema", headers={"Accept": "application/vnd.acrolinx.actif+xml"})
response.raise_for_status()
schema = ET.fromstring(response.content)
```

### Search terms

`GET /terms` replaces both search calls. Filter by name with `language`, `status` and `domain`; repeat `status` or `domain` to match any of several. A domain also matches its sub-domains.

`match` says how `query` is compared, ignoring case:

* `contains` (the default): `query` appears anywhere in the term.
* `exact`: the whole term equals `query`.
* `wildcard`: as in v9, `%` matches any run of characters and `_` matches one. Put `\` before either to match it literally.

Results are paged with `page` and `page_size` rather than `offset` and `limit`. Each term carries its entry as `conceptuuid` and `conceptid`, which you pass to [`GET /entries`](#get-entries). To list the entries in some domains, as `searchEntries` did, pass `domain` without a `query` and collect the `conceptuuid` values, or pass `group_by_concept=true` to get every term of each matching entry.

**`Before (v9)`**

```bash title="Before (v9)"
curl -X POST "https://<your-host>/iq/services/v9/rest/terminology/searchTerms" \
  -H "Authorization: session $SESSION" \
  -H "Content-Type: application/json" \
  -d '{"criteria": {"name": "data%", "language": "en", "domains": ["Product"]},
       "pagination": {"offset": 0, "limit": 100}}'
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl -G "$BASE/terms" \
  -H "Authorization: Bearer $API_KEY" \
  --data-urlencode "query=data%" \
  -d match=wildcard -d language=en -d domain=Product -d page_size=100
```

**`Now (Python)`**

```python title="Now (Python)"
response = api.get(
    "/terms",
    params=[
        ("query", "data%"),
        ("match", "wildcard"),
        ("language", "en"),
        ("domain", "Product"),
        ("page_size", "100"),
    ],
)
response.raise_for_status()
for term in response.json()["terms"]:
    print(term["conceptuuid"], term["uuid"], term["surface"]["surface"])
```

> **Note**
>
> An unknown language, status or domain name returns `400` naming it, instead of an empty result.

### Get entries

`GET /entries/{entry}` returns whole entries as one ACTIF document. Pass up to 100 entry uuids or entry ids, separated by commas; each value is tried as a uuid first, then as an id. The document holds every language and the `<schema>`, and returns `404` if none of the values match.

**`Before (v9)`**

```bash title="Before (v9)"
curl "https://<your-host>/iq/services/v9/rest/terminology/entries/$ENTRY_UUID,$OTHER_ENTRY_UUID" \
  -H "Authorization: session $SESSION" \
  -H "Accept: application/vnd.acrolinx.actif+xml"
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl "$BASE/entries/$ENTRY_UUID,$OTHER_ENTRY_UUID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Accept: application/vnd.acrolinx.actif+xml"
```

**`Now (Python)`**

```python title="Now (Python)"
response = api.get(
    f"/entries/{entry_uuid},{other_entry_uuid}",
    headers={"Accept": "application/vnd.acrolinx.actif+xml"},
)
response.raise_for_status()
document = ET.fromstring(response.content)
```

Values come back as stored, without default values filled in, so a document you fetch can be posted back without changing anything.

### Create or update entries

`POST /entries` takes an ACTIF 1.0 document of up to 500 entries and 10 MB, with `Content-Type` set to `application/vnd.acrolinx.actif+xml`, `application/xml` or `text/xml`.

* An entry whose `entry/uuid` matches a stored entry replaces it. Any other entry is created, keeping the `entry/uuid` and `term/uuid` values it carries.
* Terms are matched by `term/uuid`. A stored term the entry leaves out is deleted, and a field the entry leaves empty is cleared.
* The term in `<head>` becomes the entry's head term.
* Dates, users, frequency and history events in the document are ignored; the server records them.

The document is stored entirely or not at all. The response lists each stored entry with its `uuid`, `id` and whether it was `created`.

**`Before (v9)`**

```bash title="Before (v9)"
curl -X POST "https://<your-host>/iq/services/v9/rest/terminology/entries" \
  -H "Authorization: session $SESSION" \
  -H "Content-Type: application/vnd.acrolinx.actif+xml" \
  --data-binary @entries.xml
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl -X POST "$BASE/entries" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/vnd.acrolinx.actif+xml" \
  --data-binary @entries.xml
# -> {"entries": [{"uuid": "...", "id": "...", "created": true}]}
```

**`Now (Python)`**

```python title="Now (Python)"
from pathlib import Path

response = api.post(
    "/entries",
    content=Path("entries.xml").read_bytes(),
    headers={"Content-Type": "application/vnd.acrolinx.actif+xml"},
)
response.raise_for_status()
print(response.json()["entries"])
```

A minimal document with one new entry:

**`entries.xml`**

```xml title="entries.xml"
<actif xmlns="http://www.acrolinx.com" version="1.0">
  <data>
    <entry>
      <head>
        <term>
          <name>data center</name>
          <language>en</language>
          <value field="term/status">preferred</value>
          <value field="term/domain">Product</value>
        </term>
      </head>
      <term>
        <name>datacenter</name>
        <language>en</language>
        <value field="term/status">deprecated</value>
        <value field="term/domain">Product</value>
      </term>
    </entry>
  </data>
</actif>
```

#### Change part of an entry

Because a posted entry replaces the stored one, change an entry the way v9 integrations already do: fetch it, edit the document and post it back. This adds a synonym; changing a status or a domain is the same edit on `term/status` or `term/domain`.

**`Python`**

```python title="Python"
NS = "http://www.acrolinx.com"
ET.register_namespace("", NS)

response = api.get(f"/entries/{entry_uuid}", headers={"Accept": "application/vnd.acrolinx.actif+xml"})
response.raise_for_status()
document = ET.fromstring(response.content)

entry = document.find(f".//{{{NS}}}entry")
term = ET.SubElement(entry, f"{{{NS}}}term")
ET.SubElement(term, f"{{{NS}}}name").text = "data centre"
ET.SubElement(term, f"{{{NS}}}language").text = "en"
ET.SubElement(term, f"{{{NS}}}value", field="term/status").text = "admitted"

api.post(
    "/entries",
    content=ET.tostring(document),
    headers={"Content-Type": "application/vnd.acrolinx.actif+xml"},
).raise_for_status()
```

#### Errors

Names are resolved against `GET /schema`. A language, status, domain, field or list value that isn't there is an error, not created on the fly. A `400` lists every problem in the document at once, and nothing is stored:

```json
{
  "detail": "The entries were not stored",
  "status": 400,
  "request_id": "...",
  "errors": ["entry 1, term 'data center': unknown domain 'Prodcut'"]
}
```

### Delete an entry

`DELETE /entries/{entry}` deletes an entry and all of its terms and returns `204`.

**`Before (v9)`**

```bash title="Before (v9)"
curl -X DELETE "https://<your-host>/iq/services/v9/rest/terminology/entries/$ENTRY_UUID" \
  -H "Authorization: session $SESSION"
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl -X DELETE "$BASE/entries/$ENTRY_UUID" -H "Authorization: Bearer $API_KEY"
```

**`Now (Python)`**

```python title="Now (Python)"
api.delete(f"/entries/{entry_uuid}").raise_for_status()
```

### Propose a term

`POST /terms/contribute` replaces `/candidates`. It creates a new entry with the `proposed` status for a terminologist to review, and records the comment in the term's history. It takes the language and at least one domain as category ids, which `GET /category-types` and `GET /categories` look up from names. It returns `403` if your organization has term contribution turned off.

**`Before (v9)`**

```bash title="Before (v9)"
curl -X POST "https://<your-host>/iq/services/v9/rest/terminology/candidates" \
  -H "AUTHTOKEN: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"surface": "edge node", "language": "en", "comment": "Used in the 4.2 release notes"}'
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl -X POST "$BASE/terms/contribute" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"surface": "edge node", "language_id": "'"$LANGUAGE_ID"'",
       "category_ids": ["'"$DOMAIN_ID"'"], "comment": "Used in the 4.2 release notes"}'
```

**`Now (Python)`**

```python title="Now (Python)"
def category_id(type_name: str, name: str) -> str:
    types = api.get("/category-types").raise_for_status().json()
    type_id = next(t["id"] for t in types if t["name"] == type_name)
    categories = api.get("/categories", params={"category_type_id": type_id})
    return next(c["id"] for c in categories.raise_for_status().json() if c["name"] == name)


response = api.post(
    "/terms/contribute",
    json={
        "surface": "edge node",
        "language_id": category_id("language", "en"),
        "category_ids": [category_id("domain", "Product")],
        "comment": "Used in the 4.2 release notes",
    },
)
response.raise_for_status()
```

### Publish

Changes reach checks once you publish, as they did after `deployTerminology`. `POST /publish` returns a job right away; poll `GET /jobs/{job_id}` until its `status` is `succeeded` or `failed`. Publishing takes from a few seconds to several minutes.

**`Before (v9)`**

```bash title="Before (v9)"
curl -X PUT "https://<your-host>/iq/services/v9/rest/terminology/deployTerminology" \
  -H "Authorization: session $SESSION"
```

**`Now (cURL)`**

```bash title="Now (cURL)"
JOB_ID=$(curl -s -X POST "$BASE/publish" -H "Authorization: Bearer $API_KEY" | jq -r .id)

until STATUS=$(curl -s "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $API_KEY" | jq -r .status) \
  && [ "$STATUS" != queued ] && [ "$STATUS" != running ]; do
  sleep 5
done
echo "$STATUS"
```

**`Now (Python)`**

```python title="Now (Python)"
import time

job = api.post("/publish").raise_for_status().json()
while job["status"] in ("queued", "running"):
    time.sleep(5)
    job = api.get(f"/jobs/{job['id']}").raise_for_status().json()
print(job["status"], job.get("error"))
```

### Back up and restore

`GET /export/xml-dump` downloads all of your terminology as an XML dump, and `POST /import/xml-dump` restores one. `GET /export/actif` downloads the ACTIF document that publishing makes live.

> **Warning**
>
> An import replaces **all** of your organization's terminology with the dump.

The import runs in the background like a publish: it returns a job to poll. While it runs, other writes return `409`.

**`Before (v9)`**

```bash title="Before (v9)"
curl "https://<your-host>/iq/services/v9/rest/terminology/dump/$SESSION"
# -> {"dumpFileUrl": "...", "isReady": true, ...}
```

**`Now (cURL)`**

```bash title="Now (cURL)"
curl "$BASE/export/xml-dump" -H "Authorization: Bearer $API_KEY" -o terminology-dump.xml
curl "$BASE/export/actif" -H "Authorization: Bearer $API_KEY" -o terminology.actif.xml

# Restore: replaces ALL terminology
curl -X POST "$BASE/import/xml-dump" \
  -H "Authorization: Bearer $API_KEY" \
  -F "file=@terminology-dump.xml;type=application/xml"
# -> a job; poll GET /jobs/{job_id} as for publishing
```

**`Now (Python)`**

```python title="Now (Python)"
Path("terminology-dump.xml").write_bytes(
    api.get("/export/xml-dump").raise_for_status().content
)

# Restore: replaces ALL terminology
with open("terminology-dump.xml", "rb") as dump:
    job = api.post(
        "/import/xml-dump",
        files={"file": ("terminology-dump.xml", dump, "application/xml")},
    ).raise_for_status().json()
```

## What behaves differently

* **Entries are complete.** A posted entry replaces the stored one. Fetch, edit and post back to change only what you edit.
* **Unknown names are errors.** Nothing is created implicitly; add new languages, statuses, domains or fields first, in the console or with the category and custom field endpoints.
* **Status codes.**
  * `POST /entries` returns `201` if any entry was new and `200` otherwise. There is no `204` for an unchanged entry.
  * `DELETE /entries/{entry}` of an unknown entry returns `404`, not `410`.
  * Unknown names and invalid documents return `400` with an `errors` list.
* **Publishing and importing are jobs.** Both return immediately. Only one runs per organization at a time; a second returns `409`.
* **Formats.** Entries and the schema are ACTIF 1.0 XML only. ACTIF JSON and MTF are not accepted.

## Not available

* Sessions (`requestSession`, `releaseSession`): send the API key on every request instead.
* Saved filters (`GET /filters` and `criteria.filter`): pass the filter's conditions to `GET /terms`.
* `findTerms`: use `GET /terms`, then `GET /entries/{entry}` for the full documents.
* `lookupLinkedTerms`: use `GET /terms/{term_id}/linked`.
* `importTerminology` and `exportTerminology` with saved configurations: use `POST /entries` for partial updates, and the XML dump or ACTIF export for whole termbases.