Skip to content
TYPO3API Hub

Cookbook

We recorded the responses on this page on TYPO3 14.3.10 with the content of EXT:introduction. The mark … shows a shortened response. The fields of your records depend on the TCA of your installation, see API documentation (OpenAPI).

The recipes get the address of the instance and a token from the environment:

bash
export TYPO3_URL='https://example.org'
export TYPO3_TOKEN='<token>'

Read all records of a table#

A list returns not more than limit records. The maximum value of limit is 200. If next is not null, send its value as cursor to get the next page. Without language, the list contains the records of the default language and of all languages. Set language to get translations.

php
function allRecords(string $table, int $limit = 100): Generator
{
    $base = getenv('TYPO3_URL') . '/typo3/api/v1';
    $context = stream_context_create(['http' => [
        'header' => 'Authorization: Bearer ' . getenv('TYPO3_TOKEN'),
        'ignore_errors' => true,
    ]]);
    $query = ['limit' => $limit];
    do {
        $url = "$base/records/$table?" . http_build_query($query);
        $body = file_get_contents($url, false, $context);
        $page = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
        if (isset($page['status'])) {
            $message = $page['status'] . ' ' . $page['detail'];
            throw new RuntimeException($message);
        }
        foreach ($page['items'] as $record) {
            yield $record;
        }
        $query['cursor'] = $page['next'];
    } while ($page['next'] !== null);
}

foreach (allRecords('pages') as $page) {
    echo $page['uid'], ' ', $page['label'], PHP_EOL;
}
javascript
const base = process.env.TYPO3_URL + '/typo3/api/v1';
const headers = { Authorization: `Bearer ${process.env.TYPO3_TOKEN}` };

async function* allRecords(table, limit = 100) {
    let cursor = null;
    do {
        const query = new URLSearchParams({ limit });
        if (cursor) query.set('cursor', cursor);
        const url = `${base}/records/${table}?${query}`;
        const response = await fetch(url, { headers });
        if (!response.ok) {
            const problem = await response.json();
            throw new Error(`${problem.status} ${problem.detail}`);
        }
        const page = await response.json();
        yield* page.items;
        cursor = page.next;
    } while (cursor);
}

for await (const page of allRecords('pages')) {
    console.log(page.uid, page.label);
}
typescript
interface RecordReference {
    uid: number;
    pid: number;
    label: string;
    type: string | null;
    hidden: boolean | null;
    language: number | null;
}

interface RecordList {
    items: RecordReference[];
    next: string | null;
}

const base = `${process.env.TYPO3_URL}/typo3/api/v1`;
const headers = { Authorization: `Bearer ${process.env.TYPO3_TOKEN}` };

async function* allRecords(
    table: string,
    limit = 100,
): AsyncGenerator<RecordReference> {
    let cursor: string | null = null;
    do {
        const query = new URLSearchParams({ limit: String(limit) });
        if (cursor) query.set('cursor', cursor);
        const url = `${base}/records/${table}?${query}`;
        const response = await fetch(url, { headers });
        if (!response.ok) {
            const problem = await response.json();
            throw new Error(`${problem.status} ${problem.detail}`);
        }
        const page = (await response.json()) as RecordList;
        yield* page.items;
        cursor = page.next;
    } while (cursor);
}

for await (const page of allRecords('pages')) {
    console.log(page.uid, page.label);
}

Find records#

Use the filters pid, type, language and search. The filter search uses the label field of the table:

bash
curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
    "$TYPO3_URL/typo3/api/v1/records/tt_content" \
    -d pid=3 -d search=Hello -d limit=1
Response
json
{
    "items": [
        {
            "uid": 227,
            "pid": 3,
            "label": "Hello from the API",
            "type": "text",
            "hidden": false,
            "language": 0
        }
    ],
    "next": null
}

A list contains only these fields. To get the complete record, use GET /typo3/api/v1/records/tt_content/227.

Create a content element#

The path contains the table and the page. _schema must match the table. This action needs the scopes content:read and content:write.

bash
curl -i -X POST "$TYPO3_URL/typo3/api/v1/records/tt_content/3" \
    -H "Authorization: Bearer $TYPO3_TOKEN" \
    -H 'Content-Type: application/json' \
    -d '{
        "record": {
            "_schema": "tt_content",
            "CType": "text",
            "header": "Hello from the API",
            "bodytext": "<p>Created by an import.</p>"
        }
    }'

The status of the response is 201. The Location header contains the URL of the new record. The body contains the complete record:

Response (shortened)
text
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: https://example.org/typo3/api/v1/records/tt_content/227

{
    "record": {
        "_schema": "tt_content",
        "uid": 227,
        "pid": 3,
        "CType": "text",
        "header": "Hello from the API",
        …
    }
}

The target must be a page. This action cannot create records with pid 0. The status of the response is then 400, with the detail Target page not found.

Change a single field#

PATCH changes only the given fields and returns the complete record:

bash
curl -X PATCH "$TYPO3_URL/typo3/api/v1/records/tt_content/227" \
    -H "Authorization: Bearer $TYPO3_TOKEN" \
    -H 'Content-Type: application/json' \
    -d '{"record": {"_schema": "tt_content", "header": "New header"}}'

DELETE on the same address returns the status 204. After this, a request for the record returns the status 404.

Upload a file#

The request body is the content of the file. folder and name are query parameters:

bash
curl -X POST "$TYPO3_URL/typo3/api/v1/files?folder=1:/&name=report.txt" \
    -H "Authorization: Bearer $TYPO3_TOKEN" \
    -H 'Content-Type: text/plain' \
    --data-binary @report.txt
Response with status 201 (shortened)
json
{
    "_schema": "sys_file",
    "uid": 81,
    "identifier": "1:/report.txt",
    "folder": "1:/",
    "name": "report.txt",
    "mimeType": "text/plain",
    "size": 5,
    …
    "url": "https://example.org/fileadmin/report.txt",
    "metadata": {"_schema": "sys_file_metadata", "uid": 81},
    "permissions": {
        "read": true,
        "write": true,
        "rename": true,
        "delete": true
    }
}

This action needs the scopes content:read and content:write. GET /typo3/api/v1/storages lists the file storages and their entry folders (mounts). Use it to find the identifier for folder. See File and folder actions.

Handle errors#

All errors have the same format, so one function can handle them. These responses are from a test instance:

Status Example detail What to do
400 The request contains invalid parameters Do not repeat the request. Fix the values listed in errors.
401 Missing "Authorization" header, App is not available Check the token. The app may be disabled or its secret changed.
403 Missing scope: content:write, No access to workspace 99 Ask an administrator for the scope or the permission. The header WWW-Authenticate names a missing scope.
404 Record not found, Table not found Check the URL. For an app, the action may also not be offered.
500 The action failed, the error is logged with request ID … Report the requestId, which identifies the entry in the log.
Status 400, with one entry per problem
json
{
    "type": "https://typo3.org/go/exception/CMS/1793870606",
    "title": "Bad Request",
    "status": 400,
    "detail": "The request contains invalid parameters",
    "errors": [
        {
            "parameter": "limit",
            "detail": "String value found, but an integer is required"
        }
    ]
}

For values in the request body, an entry has a pointer, for example #/record/sectionIndex, not a parameter.

Run the OAuth flow#

An application with many users gets its tokens from the authorization code flow, see OAuth 2.0.

Find the endpoints

The metadata of the authorization server contains them:

bash
curl "$TYPO3_URL/.well-known/oauth-authorization-server"

Send the user to the authorization endpoint

Create a random code_verifier for each login. Send its SHA-256 hash with base64url encoding as code_challenge:

text
https://example.org/typo3/oauth/authorize
    ?response_type=code
    &client_id=<identifier of the app>
    &redirect_uri=https://app.example/callback
    &scope=content:read
    &state=<random value>
    &code_challenge=<hash of code_verifier>
    &code_challenge_method=S256

Exchange the code on your server

After the approval, TYPO3 redirects the user to the redirect_uri with code and state. Check state. Then exchange the code:

bash
curl -X POST "$TYPO3_URL/typo3/api/v1/oauth/token" \
    -u '<identifier of the app>:<client secret>' \
    -d grant_type=authorization_code \
    -d code="$CODE" \
    -d redirect_uri=https://app.example/callback \
    -d code_verifier="$CODE_VERIFIER"
Response
json
{
    "token_type": "Bearer",
    "expires_in": 3600,
    "access_token": "<access token>",
    "refresh_token": "<refresh token>"
}

Request a new access token

Do this step before the access token expires:

bash
curl -X POST "$TYPO3_URL/typo3/api/v1/oauth/token" \
    -u '<identifier of the app>:<client secret>' \
    -d grant_type=refresh_token \
    -d refresh_token="$REFRESH_TOKEN"

Generate a client#

GET /typo3/api/v1/schema returns an OpenAPI 3.1 document, which describes the record types of your installation. Save it and pass it to the client generator of your choice:

bash
curl -H "Authorization: Bearer $TYPO3_TOKEN" \
    "$TYPO3_URL/typo3/api/v1/schema" -o openapi.json

Generate the client from the document of the installation you work against. Tables and fields depend on its extensions and configuration.