# url2.us -- URL Shortener with Analytics

## API Documentation

Base URL: `https://url2.us`

## Authentication

The API supports two authentication methods. Pass either in the `Authorization` header.

### JWT Bearer Token

Obtained from the `/api/auth/register` or `/api/auth/login` endpoints. Short-lived access tokens with refresh token rotation.

```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```

### API Key

Create API keys in your dashboard settings. Prefixed with `su_live_`. Best for server-to-server integrations.

```
Authorization: Bearer su_live_abc123...
```

## Endpoints

### Auth

#### `POST /api/auth/register`

Create a new account. Returns JWT access and refresh tokens.

**Authentication:** None

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | yes | Account email address |
| password | string | yes | Account password |
| anonymousToken | string | no | Token from anonymous link creation to claim those links |

**Example request body:**
```json
{"email": "dev@example.com", "password": "securepassword123"}
```

**Response:**
```json
{"accessToken": "eyJhbG...", "refreshToken": "dGhpcy...", "expiresAt": "2026-03-27T14:30:00Z"}
```

#### `POST /api/auth/login`

Sign in with email and password. Returns JWT access and refresh tokens.

**Authentication:** None

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | yes | Account email address |
| password | string | yes | Account password |

**Example request body:**
```json
{"email": "dev@example.com", "password": "securepassword123"}
```

**Response:**
```json
{"accessToken": "eyJhbG...", "refreshToken": "dGhpcy...", "expiresAt": "2026-03-27T14:30:00Z"}
```

#### `POST /api/auth/refresh`

Exchange a refresh token for a new access token and refresh token (rotation). The old refresh token is revoked.

**Authentication:** None

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| refreshToken | string | yes | The refresh token from a previous login or refresh |

**Example request body:**
```json
{"refreshToken": "dGhpcy..."}
```

**Response:**
```json
{"accessToken": "eyJhbG...", "refreshToken": "bmV3dG...", "expiresAt": "2026-03-27T15:30:00Z"}
```

#### `GET /api/auth/me`

Get current user info including plan, link limit, and email verification status.

**Authentication:** Required

**Response:**
```json
{"id": 1, "email": "dev@example.com", "plan": "pro", "linksLimit": 0, "createdAt": "2026-01-15T10:00:00Z", "emailVerified": true}
```

### Links

#### `POST /api/links`

Create a new short link. Optionally specify a custom slug, title, tags, expiry date, and custom domain.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| destinationUrl | string | yes | The URL to redirect to (must be http or https) |
| slug | string | no | Custom slug (auto-generated 7-char Base62 if omitted) |
| title | string | no | Human-readable title for the link |
| tags | string[] | no | Array of tags for organizing and filtering |
| domainId | number | no | Custom domain ID (from GET /api/domains). Omit to use url2.us |
| expiresAt | string | no | ISO 8601 expiry date (link deactivates after this time) |

**Example request body:**
```json
{"destinationUrl": "https://example.com/my-long-url", "slug": "my-link", "title": "My Example Link", "tags": ["marketing", "q1"], "domainId": 1, "expiresAt": "2026-12-31T23:59:59Z"}
```

**Response:**
```json
{"id": 42, "slug": "my-link", "shortUrl": "https://links.example.com/my-link", "destinationUrl": "https://example.com/my-long-url", "title": "My Example Link", "tags": ["marketing", "q1"], "isActive": true, "expiresAt": null, "createdAt": "2026-03-27T12:00:00Z"}
```

#### `GET /api/links`

List your short links with optional search, tag filtering, and pagination. Query params: `search`, `tags`, `status`, `page`, `pageSize`.

**Authentication:** Required

**Response:**
```json
{"items": [{"id": 42, "slug": "my-link", "shortUrl": "https://url2.us/my-link", "destinationUrl": "https://example.com/my-long-url", "isActive": true, "createdAt": "2026-03-27T12:00:00Z"}], "page": 1, "pageSize": 20, "totalCount": 1, "totalPages": 1}
```

#### `GET /api/links/{id}`

Get a single link by ID.

**Authentication:** Required

**Response:**
```json
{"id": 42, "slug": "my-link", "shortUrl": "https://url2.us/my-link", "destinationUrl": "https://example.com/my-long-url", "title": "My Example Link", "tags": ["marketing", "q1"], "isActive": true, "expiresAt": null, "createdAt": "2026-03-27T12:00:00Z"}
```

#### `PUT /api/links/{id}`

Update a link. All fields are optional -- only provided fields are changed. Set `clearExpiry: true` to remove an expiry date.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| destinationUrl | string | no | New destination URL |
| title | string | no | New title |
| tags | string[] | no | New tags (replaces existing tags) |
| isActive | boolean | no | Enable or disable the link |
| expiresAt | string | no | New ISO 8601 expiry date |
| clearExpiry | boolean | no | Set to true to remove the expiry date |

**Example request body:**
```json
{"destinationUrl": "https://example.com/new-url", "title": "Updated Title", "tags": ["updated"], "isActive": false, "expiresAt": "2027-06-01T00:00:00Z"}
```

**Response:**
```json
{"id": 42, "slug": "my-link", "shortUrl": "https://url2.us/my-link", "destinationUrl": "https://example.com/new-url", "title": "Updated Title", "tags": ["updated"], "isActive": false, "expiresAt": "2027-06-01T00:00:00Z", "createdAt": "2026-03-27T12:00:00Z"}
```

#### `DELETE /api/links/{id}`

Delete a short link. The slug becomes available for reuse.

**Authentication:** Required

**Response:**
```
(empty response body, 204 No Content)
```

### Analytics

#### `GET /api/links/{id}/analytics`

Get click analytics for a link. Query params: `from` (ISO 8601), `to`, `bucket` (hour/day/week/month), `include_bots`.

**Authentication:** Required

**Response:**
```json
{"totalClicks": 1523, "uniqueClicks": 892, "humanClicks": 1400, "botClicks": 123, "clicksOverTime": [{"time": "2026-03-01T00:00:00Z", "clicks": 45, "uniqueClicks": 32}], "topReferrers": [{"referrer": "twitter.com", "clicks": 320}], "countries": [{"countryCode": "US", "clicks": 650}], "devices": [{"deviceType": "Desktop", "clicks": 800}], "browsers": [{"browser": "Chrome", "clicks": 700}]}
```

### QR Codes

#### `GET /api/links/{id}/qr`

Generate a QR code image for a short link. Query params: `format` (png/svg), `size` (px), `fg` (hex color), `bg` (hex color).

**Authentication:** Required

**Response:**
```
(binary PNG or SVG image data)
```

### Custom Domains

Custom domains let you use your own domain (e.g., `links.example.com`) for short links instead of `url2.us`. Available on all paid plans.

**Custom domain workflow:**

1. **Register domain:** `POST /api/domains` with your domain name
2. **Configure DNS:** Create a CNAME record pointing your domain to the `cnameTarget` returned in the response
3. **Verify:** `GET /api/domains/{id}/verify` to confirm DNS is configured (TLS certificate is auto-provisioned)
4. **Create links:** `POST /api/links` with `domainId` set to the domain's ID to create links on your custom domain

#### `POST /api/domains`

Add a custom domain to your account. Returns the CNAME target for DNS configuration.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| domain | string | yes | The domain hostname (e.g., links.example.com) |

**Example request body:**
```json
{"domain": "links.example.com"}
```

**Response:**
```json
{"id": 1, "domain": "links.example.com", "verified": false, "cnameTarget": "direct.url2.us", "createdAt": "2026-03-27T12:00:00Z"}
```

#### `GET /api/domains`

List your custom domains with IDs and verification status. Use the `id` from this response as `domainId` when creating links.

**Authentication:** Required

**Response:**
```json
[{"id": 1, "domain": "links.example.com", "verified": true, "cnameTarget": "direct.url2.us", "createdAt": "2026-03-27T12:00:00Z"}]
```

#### `GET /api/domains/{id}/verify`

Check DNS verification status of a custom domain.

**Authentication:** Required

**Response:**
```json
{"id": 1, "domain": "links.example.com", "verified": true}
```

#### `DELETE /api/domains/{id}`

Remove a custom domain from your account.

**Authentication:** Required

**Response:**
```
(empty response body, 204 No Content)
```

### API Keys

#### `POST /api/keys`

Create a new API key. The full key is only returned once on creation.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | yes | A descriptive name for the API key |
| expiresAt | string | no | ISO 8601 expiry date (key never expires if omitted) |

**Example request body:**
```json
{"name": "My CI/CD Key", "expiresAt": "2027-01-01T00:00:00Z"}
```

**Response:**
```json
{"id": 1, "name": "My CI/CD Key", "key": "su_live_abc123...", "keyPrefix": "su_live_", "createdAt": "2026-03-27T12:00:00Z", "expiresAt": "2027-01-01T00:00:00Z"}
```

#### `GET /api/keys`

List your API keys (key values are not returned, only prefixes).

**Authentication:** Required

**Response:**
```json
[{"id": 1, "name": "My CI/CD Key", "key": null, "keyPrefix": "su_live_", "createdAt": "2026-03-27T12:00:00Z", "expiresAt": "2027-01-01T00:00:00Z"}]
```

#### `DELETE /api/keys/{id}`

Revoke an API key. It will immediately stop working.

**Authentication:** Required

**Response:**
```
(empty response body, 204 No Content)
```

### Webhooks

#### `POST /api/webhooks`

Create a webhook subscription. Supported types: Slack, Discord, Custom. Supported event types: `link.created`, `click.milestone`, `link.flagged`. Custom type requires an HMAC secret.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | yes | A descriptive name for the webhook |
| type | string | yes | Webhook type: Slack, Discord, or Custom |
| url | string | yes | The URL to receive webhook POST requests |
| eventTypes | string[] | yes | Events to subscribe to: link.created, click.milestone, link.flagged |
| hmacSecret | string | no | HMAC signing secret (required for Custom type, ignored for Slack/Discord) |

**Example request body:**
```json
{"name": "My Webhook", "type": "Custom", "url": "https://example.com/webhook", "eventTypes": ["link.created", "click.milestone"], "hmacSecret": "my-secret-key"}
```

**Response:**
```json
{"id": 1, "name": "My Webhook", "type": "Custom", "url": "https://example.com/webhook", "eventTypes": ["link.created", "click.milestone"], "isActive": true, "createdAt": "2026-03-27T12:00:00Z", "updatedAt": null}
```

#### `GET /api/webhooks`

List your webhook subscriptions. URLs are partially redacted in the response.

**Authentication:** Required

**Response:**
```json
[{"id": 1, "name": "My Webhook", "type": "Custom", "url": "https://example.com/***", "eventTypes": ["link.created"], "isActive": true, "createdAt": "2026-03-27T12:00:00Z"}]
```

#### `PUT /api/webhooks/{id}`

Update a webhook subscription. All fields are optional -- only provided fields are changed.

**Authentication:** Required

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | no | New name for the webhook |
| url | string | no | New delivery URL |
| eventTypes | string[] | no | New event subscriptions (replaces existing) |
| isActive | boolean | no | Enable or disable the webhook |

**Example request body:**
```json
{"name": "Updated Name", "url": "https://example.com/new-hook", "eventTypes": ["link.created", "link.flagged"], "isActive": false}
```

**Response:**
```json
{"id": 1, "name": "Updated Name", "type": "Custom", "url": "https://example.com/new-hook", "eventTypes": ["link.created", "link.flagged"], "isActive": false, "createdAt": "2026-03-27T12:00:00Z", "updatedAt": "2026-03-28T09:00:00Z"}
```

#### `DELETE /api/webhooks/{id}`

Delete a webhook subscription.

**Authentication:** Required

**Response:**
```
(empty response body, 204 No Content)
```

#### `POST /api/webhooks/{id}/test`

Send a test `link.created` event to the webhook URL and report whether delivery succeeded.

**Authentication:** Required

**Response:**
```json
{"success": true, "httpStatusCode": 200, "message": "Test webhook delivered successfully"}
```

#### `GET /api/webhooks/{id}/deliveries`

List delivery history for a webhook subscription. Query params: `page`, `pageSize`.

**Authentication:** Required

**Response:**
```json
{"items": [{"id": 100, "eventType": "link.created", "status": "Delivered", "attemptCount": 1, "httpStatusCode": 200, "createdAt": "2026-03-27T12:00:00Z", "deliveredAt": "2026-03-27T12:00:01Z"}], "page": 1, "pageSize": 20, "totalCount": 1, "totalPages": 1}
```

### Redirect

#### `GET /{slug}`

Redirect to the destination URL. Always returns 302 (never 301) so every click is tracked.

**Authentication:** None

**Response:**
```
HTTP/1.1 302 Found
Location: https://example.com/my-long-url
```

### Health

#### `GET /health`

Check the health of the API and its dependencies.

**Authentication:** None

**Response:**
```json
{"status": "Healthy", "totalDuration": "00:00:00.023", "entries": {"postgres": {"status": "Healthy"}, "redis": {"status": "Healthy"}}}
```

## Rate Limits

API requests are rate-limited per user and per plan. When you exceed the limit, the API returns `429 Too Many Requests` with a `Retry-After` header.

| Plan | API Requests | Link Creation |
|------|-------------|---------------|
| Free | 20/minute | 10/hour |
| Starter | 85/minute | 50/hour |
| Pro | 1000/minute | 100/hour |
| Business | 2500/minute | 500/hour |
| Agency | 10000/minute | 1000/hour |

## Error Format

The API uses standard HTTP status codes and returns JSON error responses following RFC 7807 Problem Details.

```json
{"type": "https://tools.ietf.org/html/rfc7807", "title": "Rate limit exceeded", "status": 429, "detail": "Try again in 5 minutes."}
```

Common status codes: `200` OK, `201` Created, `204` No Content, `400` Bad Request, `401` Unauthorized, `403` Forbidden, `404` Not Found, `409` Conflict, `429` Rate Limited, `500` Server Error.


---

**url2.us** | Support: support@url2.us