# SDKs & examples

Tino's official SDKs for Python, Node.js and PHP are open source at [tinovn/tino-sdk](https://github.com/tinovn/tino-sdk) (MIT license). Each route has a function `client.<group>.<action>()`; the SDK encodes queries such as `filter[key]=value`, reads errors in the body (including HTTP 200 with `error`) and throws typed errors.

Updated: 2026-10-27.

## Installation

| Language | Install command | Current release | Requirements |
| --- | --- | --- | --- |
| Python | `pip install tino-userapi` | 1.2.0 (PyPI) | Python 3.8+, no other dependencies |
| Node.js | `npm install @tinovn/tino-sdk` | 1.1.0 (npm) | Node.js 18+, ESM, TypeScript definitions included, no other dependencies |
| PHP | `composer require tinovn/tino-sdk` | 1.1.0 (Packagist) | PHP 7.4+, PSR-4, no other dependencies |

## Creating a client

Point the SDK at the gateway `https://api.tino.vn/v1` and pass a token: a [personal API token](/en/docs/personal-tokens) (`tinopat_`) or an access token from [Sign in with Tino](/en/docs/oauth) (`tinoat_`). Read the token from the `TINO_TOKEN` environment variable; do not write it into source code.

```python
import os
from tino_userapi import TinoClient

client = TinoClient(base_url="https://api.tino.vn/v1", token=os.environ["TINO_TOKEN"])
print(client.account.get_details())
```

```javascript
import { TinoClient } from "@tinovn/tino-sdk";

const client = new TinoClient({ baseUrl: "https://api.tino.vn/v1", token: process.env.TINO_TOKEN });
console.log(await client.account.getDetails());
```

```php
<?php
require "vendor/autoload.php";

$client = new Tino\UserApi\TinoClient(["baseUrl" => "https://api.tino.vn/v1", "token" => getenv("TINO_TOKEN")]);
print_r($client->account->getDetails());
```

Do not pass an OAuth refresh token to the SDK: the SDK refreshes with its own sign-in method, not through `/oauth/token`. When the OAuth access token expires (24 hours by default), the app [refreshes the token](/en/docs/oauth#refreshing-and-revoking-tokens) and then creates a client with the new token.

## Calling a route

Each route page in the [API Reference](/en/docs/reference) has an SDK call for that route, in the request examples column. Path parameters are positional arguments; the body is keyword arguments (Python), an object (Node.js) or an array (PHP). Example of adding a DNS record (scope `dns:write`):

```python
client.dns.create_record("100003", "500001", name="www", type="A", content="203.0.113.10", ttl=3600, priority=0)
```

```javascript
await client.dns.createRecord("100003", "500001", { name: "www", type: "A", content: "203.0.113.10", ttl: 3600, priority: 0 });
```

```php
$client->dns->createRecord("100003", "500001", ["name" => "www", "type" => "A", "content" => "203.0.113.10", "ttl" => 3600, "priority" => 0]);
```

## Routes without a dedicated function

Some gateway routes do not have a function in the SDK yet (the route page says so). Call them with `request`, using the path without the `/v1` prefix (the base URL already includes it):

```python
records = client.request("GET", "/domain/200001/dns")
```

```javascript
const records = await client.request("GET", "/domain/200001/dns");
```

```php
$records = $client->request("GET", "/domain/200001/dns");
```

Pass the body with `body=` (Python), `{ body: … }` (Node.js) or `["body" => …]` (PHP); pass the query with `query`.

## Errors

| Situation | Python, Node.js | PHP |
| --- | --- | --- |
| HTTP 401 `invalid_token` (token wrong, expired, revoked) | `TinoAuthError` | `TinoAuthException` |
| HTTP 403 `insufficient_scope`, `ip_not_allowed` | `TinoApiError` | `TinoApiException` |
| Not found | `TinoNotFoundError` | `TinoNotFoundException` |
| Invalid request data | `TinoValidationError` | `TinoValidationException` |
| Rate limited | `TinoRateLimitError` | `TinoRateLimitException` |
| Network error, timeout | `TinoTransportError` | `TinoTransportException` |

All errors inherit from `TinoError` (`TinoException` in PHP); errors from the API carry the original error code (for example `insufficient_scope`).

## Example: services about to expire

List active services that expire within the next 30 days (scope `services:read`):

```python
import os
from datetime import date, timedelta
from tino_userapi import TinoClient

client = TinoClient(base_url="https://api.tino.vn/v1", token=os.environ["TINO_TOKEN"])
soon = (date.today() + timedelta(days=30)).isoformat()
for service in client.services.list()["services"]:
    if service["status"] == "Active" and service["expires"] <= soon:
        print(service["id"], service["domain"], service["name"], service["expires"])
```

## Example: unpaid invoices

Print due invoices (scope `billing:read`):

```javascript
import { TinoClient } from "@tinovn/tino-sdk";

const client = new TinoClient({ baseUrl: "https://api.tino.vn/v1", token: process.env.TINO_TOKEN });
const { invoices_due: due } = await client.billing.listInvoicesDue();
for (const invoice of due) console.log(invoice.number, invoice.total, invoice.currency, invoice.duedate);
```

## Example: updating an A record when the IP changes

Point `home.example.com` to a new IP, dynamic DNS style (scopes `dns:read`, `dns:write`). The DNS zone is found by domain name; `zone_id` is the zone's `domain_id`. When reading, record names are fully qualified (`home.example.com`); when adding or editing, send the relative name (`home`):

```python
import os
from tino_userapi import TinoClient

client = TinoClient(base_url="https://api.tino.vn/v1", token=os.environ["TINO_TOKEN"])
new_ip = "203.0.113.25"
zone = next(z for z in client.dns.list_zones_all()["zones"] if z["name"] == "example.com")
records = client.dns.get_zone(zone["service_id"], zone["domain_id"])["records"].values()
record = next(r for r in records if r["type"] == "A" and r["name"] == "home.example.com")
if record["content"] != new_ip:
    client.dns.update_record(zone["service_id"], zone["domain_id"], record["id"],
                             name="home", type="A", content=new_ip, ttl=record["ttl"], priority=record["priority"])
```

The examples read data in the shape of the sample responses on the route pages; try them with a token that has only the scopes they need, see [Test environment](/en/docs/sandbox).
