# TinyTo URL Shortener API

Create short links, read their click stats and manage them from your own code. Requires the **Advanced** plan.

- Base URL: `https://tinyto.me/api`
- Auth: `Authorization: Bearer <key>` (or `X-API-Key: <key>`)
- Keys are created at https://tinyto.me/dashboard/api-keys and shown once.
- All responses share one envelope: `{ "data": ..., "error": null }` on success, `{ "data": null, "error": { "code", "message" } }` on failure.

## Quick reference

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| POST | `/v1/links` | write | Create a link |
| GET | `/v1/links` | read | List links |
| GET | `/v1/links/:id` | read | Get a link |
| DELETE | `/v1/links/:id` | write | Delete a link |
| GET | `/v1/links/:id/stats` | read | Link stats |
| GET | `/v1/me` | read | Account and key |

## Authentication

Send the key as a bearer token. `X-API-Key` is accepted as an alias.

```http
Authorization: Bearer tto_live_...
```

- Keys starting with `tto_live_`; scopes `read` or `write` (write implies read).
- Optional expiry (30/90/365 days or never). Revocation is immediate.
- A key stops working with `403 forbidden` if the account leaves the Advanced plan.
- 401 = missing/invalid/expired/revoked key. 403 = read-only key on a write call.

## Conventions

- Bodies are JSON: send `Content-Type: application/json`.
- Timestamps are ISO 8601 UTC, e.g. `2026-10-30T09:14:02.000Z`.
- Identifiers are UUIDs; pass `id`, never the slug.
- Pagination: `limit` (1-100, default 50) and `offset`; the response carries a `pagination` object.
- Responses are `Cache-Control: no-store`.
- `POST /v1/links` is not idempotent: two identical requests create two links.

## Endpoints

### POST /v1/links

Creates a short link owned by the key's account. Counts against the plan's active-link allowance.

Required scope: `write`.

| Field | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `url` | body | string | yes | Destination. `http`/`https` is assumed if the scheme is missing. Max 2048 chars. |
| `slug` | body | string | no | Custom path, 3–50 alphanumeric characters. Must be free across the whole service. |
| `expires_at` | body | string | no | ISO 8601 timestamp in the future. The link stops resolving after it. |

Request:

```bash
curl -X POST https://tinyto.me/api/v1/links \
  -H "Authorization: Bearer tto_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/spring-sale",
    "slug": "spring-sale"
  }'
```

Response (201 Created):

```json
{
  "data": {
    "id": "9f1c8b7a-2d4e-4a11-9b0c-3f7f2c1d8e44",
    "short_url": "https://tinyto.me/spring-sale",
    "stats_url": "https://tinyto.me/spring-sale/stats",
    "url": "https://example.com/spring-sale",
    "slug": "spring-sale",
    "expires_at": null
  },
  "error": null
}
```

### GET /v1/links

Returns the account's links, newest first.

Required scope: `read`.

| Field | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no | Page size, 1–100. Default 50. |
| `offset` | query | integer | no | How many links to skip. Default 0. |

Request:

```bash
curl "https://tinyto.me/api/v1/links?limit=20&offset=0" \
  -H "Authorization: Bearer tto_live_..."
```

Response (200 OK):

```json
{
  "data": {
    "items": [
      {
        "id": "9f1c8b7a-...",
        "short_url": "https://tinyto.me/spring-sale",
        "url": "https://example.com/spring-sale",
        "clicks": 128,
        "created_at": "2026-10-30T09:14:02.000Z"
      }
    ],
    "pagination": {
      "total": 42,
      "limit": 20,
      "offset": 0,
      "has_more": true
    }
  },
  "error": null
}
```

### GET /v1/links/:id

Returns one link owned by the account.

Required scope: `read`.

| Field | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The link `id` returned at creation, not the slug. |

Request:

```bash
curl https://tinyto.me/api/v1/links/9f1c8b7a-... \
  -H "Authorization: Bearer tto_live_..."
```

Response (200 OK):

```json
{
  "data": {
    "id": "9f1c8b7a-...",
    "short_url": "https://tinyto.me/spring-sale",
    "url": "https://example.com/spring-sale",
    "slug": "spring-sale",
    "clicks": 128,
    "created_at": "2026-10-30T09:14:02.000Z",
    "expires_at": null,
    "password_protected": false
  },
  "error": null
}
```

### DELETE /v1/links/:id

Deletes the link and its stats. The short URL stops resolving immediately.

Required scope: `write`.

| Field | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The link `id`. |

Request:

```bash
curl -X DELETE https://tinyto.me/api/v1/links/9f1c8b7a-... \
  -H "Authorization: Bearer tto_live_..."
```

Response (200 OK):

```json
{
  "data": { "deleted": true, "id": "9f1c8b7a-..." },
  "error": null
}
```

### GET /v1/links/:id/stats

Click totals, top countries and the hour/weekday heatmap for one link. Bot traffic is excluded from the heatmap.

Required scope: `read`.

| Field | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The link `id`. |

Request:

```bash
curl https://tinyto.me/api/v1/links/9f1c8b7a-.../stats \
  -H "Authorization: Bearer tto_live_..."
```

Response (200 OK):

```json
{
  "data": {
    "id": "9f1c8b7a-...",
    "short_url": "https://tinyto.me/spring-sale",
    "total_clicks": 128,
    "countries": [
      { "country": "ES", "clicks": 74 },
      { "country": "MX", "clicks": 21 },
      { "country": "FR", "clicks": 12 }
    ],
    "heatmap": [
      { "hour": 9, "dayOfWeek": 1, "clicks": 14 },
      { "hour": 20, "dayOfWeek": 3, "clicks": 12 }
    ],
    "heatmap_shape": "hour is 0-23, day_of_week is 0 (Sunday) to 6 (Saturday)"
  },
  "error": null
}
```

### GET /v1/me

Confirms the key works and returns the plan, link usage and the key's own scope.

Required scope: `read`.

Request:

```bash
curl https://tinyto.me/api/v1/me \
  -H "Authorization: Bearer tto_live_..."
```

Response (200 OK):

```json
{
  "data": {
    "plan": "advanced",
    "link_usage": { "used": 42, "limit": null, "unlimited": true },
    "key_scope": "write"
  },
  "error": null
}
```

## MCP server

The same account is reachable over the Model Context Protocol at `https://tinyto.me/mcp`,
for Claude and ChatGPT connectors. Authentication is OAuth 2.1 with PKCE: the client
registers itself, sends the user through a consent screen and receives a bearer token.
No API key is involved, and the MCP server is available on every plan.

Tools: `list_links`, `get_link`, `get_link_stats`, `account_info` (scope `links:read`)
and `create_link` (scope `links:create`). No tool deletes or modifies an existing link.

Users can disconnect an application at https://tinyto.me/dashboard/api-keys (Connected apps),
which revokes its tokens immediately. Clients may also call `POST /oauth/revoke` (RFC 7009).

- Protected resource metadata: https://tinyto.me/.well-known/oauth-protected-resource
- Authorization server metadata: https://tinyto.me/.well-known/oauth-authorization-server

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | A required field is missing, has the wrong type, or the slug/expiry is invalid. |
| 401 | `unauthorized` | No key, or the key is invalid, expired or revoked. |
| 402 | `plan_limit_reached` | The account has no active links left on its plan. |
| 403 | `forbidden` | Read-only key used for a write call, or the plan does not include API access. |
| 404 | `not_found` | The link does not exist, or it belongs to another account. |
| 429 | `rate_limited` | More than 120 requests in a minute for this key. |
| 500 | `server_error` | Unexpected failure on our side. Safe to retry with backoff. |

## Rate limits

120 requests per minute per key. On `429` read `Retry-After` (seconds) and back off.

---

Machine-readable contract: https://tinyto.me/openapi.json

Human version with JavaScript and Python examples: https://tinyto.me/docs/api
