---
**📚 Main Documentation:** [Hoko API Documentation (llms.txt)](https://hoko.to/docs/llms.txt)
This is an individual endpoint documentation file. For the complete API reference, see the main documentation above.
---
# (PUT) Update/Upsert links
Update existing links or create new ones with upsert operations. Perfect for synchronization scenarios.
**Category:** Links
## Endpoint

PUT /api/links

The PUT endpoint implements an upsert operation, allowing you to update existing links or create new ones in a single request. This is particularly useful for synchronization scenarios where you want to ensure links exist with specific properties.

If an id is provided in the request body, the endpoint updates the existing link with that ID. If the ID does not exist, the request fails. To create new links, omit the id field.

Returns the updated or created links in the same order as the input array, making it easy to correlate requests with responses in bulk operations.

**Endpoint**

```text
PUT /api/links
```

## Authentication

Requires authentication with an API key that has the linksWrite scope.

## Request Body

The request body must be an array of link objects. Include the id field to update an existing link, or omit it to create a new one.

You can upsert up to 10,000 links in a single request. However, the actual maximum may be lower based on your subscription plan limits and available resources.

| Parameter    | Type          | Required | Location | Description                                                                                                           |
| ------------ | ------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| id           | string (UUID) | Yes      | body     | Required when updating. Omit to create a new link. If provided, it must exist in your workspace or the request fails. |
| url          | string (URL)  | No       | body     | HTTPS destination URL. Required when creating a new link (no id). Optional when updating.                             |
| collectionId | string (UUID) | No       | body     | Collection ID. Required when creating a new link (no id). Optional when updating.                                     |
| tagIds       | string[]      | No       | body     | Existing tag IDs to assign. When updating, replaces tag assignments if provided. Send `[]` to clear all tags.         |
| title        | string        | No       | body     | Link title. Only changes when provided.                                                                               |
| description  | string        | No       | body     | Link description. Only changes when provided.                                                                         |
| image        | string (URL)  | No       | body     | HTTPS preview image URL. Only changes when provided.                                                                  |
| utm          | object        | No       | body     | UTM parameters object. Insert entries accept string values; update entries may send null for individual keys to clear them. |
| expiresAt    | string/null   | No       | body     | ISO date-time when the link should expire. Send null to clear. Requires link expiration when setting a value.         |
| expiredUrl   | string/null   | No       | body     | HTTPS destination used after expiration. Send null to clear. Requires link expiration when setting a value.           |
| password     | string/null   | No       | body     | Password required before resolving the destination. Send null to clear. Requires password protection when set.        |
| cloaked      | boolean/null  | No       | body     | Render the resolved destination in an iframe. Send false/null to clear. When true, the destination is preflighted and rejected with 400 if it currently blocks iframe rendering. |
| ios          | string/null   | No       | body     | HTTPS destination for iOS visitors. Send null to clear. Requires device targeting when setting a value.               |
| android      | string/null   | No       | body     | HTTPS destination for Android visitors. Send null to clear. Requires device targeting when setting a value.           |
| geo          | object/null   | No       | body     | Uppercase country-code HTTPS destination map. Send null to clear on updates or omit/null on inserts for no geo targeting. Requires geo targeting when setting destinations. |
| externalId   | string        | No       | body     | External ID for syncing. Must be unique in workspace when provided.                                                   |
| tenantId     | string        | No       | body     | Tenant identifier. Optional.                                                                                          |
| partnerId    | string (UUID) | No       | body     | Partner ID for attribution. Optional.                                                                                 |

> **Warning: Plan Limits**
> The actual maximum number of links you can upsert per request may be lower than 10,000 based on your subscription plan limits and available resources. The system will enforce both the hard limit (10,000) and your plan-specific limits.

## Advanced Link Behavior

Advanced fields are applied only when they are set on the link and your workspace plan supports the feature. When updating, include at least one mutable field besides id; omitted fields are left unchanged. Send `null` to clear nullable fields only on entries that include `id`, and send `false` or `null` to disable `cloaked`. Entries without `id` use the same create validation as `POST /api/links`: nullable clears are not accepted except `geo: null` for no geo targeting. For tags, omit `tagIds` to keep existing assignments or send `[]` to clear them.

All URL fields accepted by this endpoint must use `https:`. This includes `url`, `image`, `expiredUrl`, `ios`, `android`, and every URL value in `geo`.

- `password`: Visitors must enter the password before Hoko resolves the final destination. A correct password stores a secure, HTTP-only cookie scoped to that short link, so the same visitor can continue without re-entering it.
- `expiresAt`: Use an ISO date-time with timezone offset, such as `2026-12-31T23:59:00Z`. When the current server time is later than `expiresAt`, the link is expired. If `expiredUrl` is set, Hoko sends visitors there. If it is not set, Hoko shows an expired-link page.
- `ios` and `android`: Device destinations are selected from the visitor's User-Agent. iOS and Android checks run before geo targeting.
- `geo`: Map ISO 3166-1 alpha-2 country codes to destination URLs, for example `{ "SA": "https://example.com/sa" }`. Use uppercase country codes. Country detection is best-effort and uses trusted edge country headers when available, then IP lookup.
- `cloaked`: Hoko renders the resolved destination in a full-page iframe. Create and update requests preflight the active destination and return 400 if it currently blocks iframe rendering.

Resolution order is: password gate, expiration, iOS/Android targeting, geo targeting, default `url`, then cloaking. Expired links do not run device or geo targeting.

## Status Codes

### 200

Links updated or created successfully.

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "url": "https://updated.com",
      "title": "Updated Link",
      "expiresAt": "2026-12-31T23:59:00Z",
      "expiredUrl": "https://updated.com/expired",
      "cloaked": true,
      "ios": "https://updated.com/ios",
      "android": "https://updated.com/android",
      "geo": {
        "US": "https://updated.com/us"
      },
      "collectionId": "550e8400-e29b-41d4-a716-446655440000",
      "tagIds": ["7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0"]
    }
  ]'
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer <API_KEY>',
		'Content-Type': 'application/json'
	},
	body: JSON.stringify([
		{
			id: '550e8400-e29b-41d4-a716-446655440000',
			url: 'https://updated.com',
			title: 'Updated Link',
			expiresAt: '2026-12-31T23:59:00Z',
			expiredUrl: 'https://updated.com/expired',
			cloaked: true,
			ios: 'https://updated.com/ios',
			android: 'https://updated.com/android',
			geo: {
				US: 'https://updated.com/us'
			},
			collectionId: '550e8400-e29b-41d4-a716-446655440000',
			tagIds: ['7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0']
		}
	])
});
const links = await response.json();
```

**Response**

```json
[
	{
		"id": "550e8400-e29b-41d4-a716-446655440000",
		"url": "https://updated.com",
		"shortUrl": "https://hoko.to/abc123",
		"qrCode": "https://hoko.to/qrcode?text=https%3A%2F%2Fhoko.to%2Fabc123%3Fqr%3D1&size=512&errorCorrection=H&foreground=000000&background=FFFFFF&margin=1&format=svg",
		"title": "Updated Link",
		"description": null,
		"image": null,
		"utm": null,
		"expiresAt": "2026-12-31T23:59:00Z",
		"expiredUrl": "https://updated.com/expired",
		"password": null,
		"cloaked": true,
		"ios": "https://updated.com/ios",
		"android": "https://updated.com/android",
		"geo": {
			"US": "https://updated.com/us"
		},
		"collectionId": "550e8400-e29b-41d4-a716-446655440000",
		"tagIds": [
			"7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0"
		],
		"externalId": null,
		"tenantId": null,
		"partnerId": null,
		"createdAt": "2024-01-01T00:00:00Z"
	}
]
```

### 400

Invalid request body (missing required fields or invalid values).

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "url": "invalid-url"
    }
  ]'
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer <API_KEY>',
		'Content-Type': 'application/json'
	},
	body: JSON.stringify([
		{
			url: 'invalid-url'
		}
	])
});
```

**Response**

```json
{
	"error": {
		"en": "Invalid request",
		"ar": "طلب غير صالح"
	}
}
```

### 401

Invalid or missing API key.

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer invalid_key"
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer invalid_key'
	}
});
```

**Response**

```json
{
	"error": {
		"en": "Invalid API key",
		"ar": "مفتاح API غير صالح"
	}
}
```

### 403

API key does not have the required linksWrite scope.

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer <API_KEY>"
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer <API_KEY>'
	}
});
```

**Response**

```json
{
	"error": {
		"en": "Missing required scopes",
		"ar": "الصلاحيات المطلوبة مفقودة"
	},
	"missingScopes": ["linksWrite"]
}
```

### 429

Rate limit exceeded. Check X-RateLimit-\* headers for details.

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer <API_KEY>"
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer <API_KEY>'
	}
});
```

**Response**

```json
{
	"error": {
		"en": "Rate limit exceeded",
		"ar": "تم تجاوز حد المعدل"
	},
	"retryAfter": 60
}
```

### 500

Unexpected server error.

**Response**

```json
{
	"error": {
		"en": "Internal server error",
		"ar": "خطأ داخلي في الخادم"
	}
}
```

## Examples

**Request**

```bash
curl -X PUT "https://hoko.to/api/links" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "url": "https://updated.com",
      "title": "Updated Link",
      "collectionId": "550e8400-e29b-41d4-a716-446655440000",
      "tagIds": ["7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0"]
    }
  ]'
```

**Request**

```javascript
const response = await fetch('https://hoko.to/api/links', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer <API_KEY>',
		'Content-Type': 'application/json'
	},
	body: JSON.stringify([
		{
			id: '550e8400-e29b-41d4-a716-446655440000',
			url: 'https://updated.com',
			title: 'Updated Link',
			collectionId: '550e8400-e29b-41d4-a716-446655440000',
			tagIds: ['7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0']
		}
	])
});
const links = await response.json();
```

**Response**

```json
[
	{
		"id": "550e8400-e29b-41d4-a716-446655440000",
		"url": "https://updated.com",
		"shortUrl": "https://hoko.to/abc123",
		"qrCode": "https://hoko.to/qrcode?text=https%3A%2F%2Fhoko.to%2Fabc123%3Fqr%3D1&size=512&errorCorrection=H&foreground=000000&background=FFFFFF&margin=1&format=svg",
		"title": "Updated Link",
		"description": null,
		"image": null,
		"utm": null,
		"expiresAt": null,
		"expiredUrl": null,
		"password": null,
		"cloaked": false,
		"ios": null,
		"android": null,
		"geo": null,
		"collectionId": "550e8400-e29b-41d4-a716-446655440000",
		"tagIds": [
			"7c4e4a56-f3bb-43e9-9dc7-2f4bda07c8f0"
		],
		"externalId": null,
		"tenantId": null,
		"partnerId": null,
		"createdAt": "2024-01-01T00:00:00Z"
	}
]
```

> **Info: Upsert Behavior**
> When updating, include at least one mutable field besides id. Only the fields you include are changed and omitted fields keep their current values. To clear an optional field, send it explicitly as null on update entries. To replace tag assignments, send tagIds; to clear all tags, send tagIds as an empty array. When creating a new link (no id), url and collectionId are required and create validation matches POST.

---

**Back to main documentation:** [Hoko API Documentation (llms.txt)](https://hoko.to/docs/llms.txt)