<!-- Source: https://docs.geopera.com/api-reference/guides/sharing · Markdown for LLMs -->

# Sharing

Share links give someone _outside_ your organization read access to one item or one
collection, gated by a password and an expiry — without minting them an account, an
API key, or any standing access to the rest of your project.

A share is a small, deliberate export: it points at exactly one target, carries a
permission level (`view` or `download`), and stops working the moment it expires or you
revoke it. This guide covers the five share operations, the one-time token they hand
back, how scoping and expiry are enforced, and how anonymous viewers redeem a link.

## The share operations

Every share capability is an operation invoked as `POST /v1/op/{operation_id}` with a
JSON body — there are no share-specific REST verbs. See
[Operations](/api-reference/operations) for the calling convention.

| Operation             | Side effect    | Scope          | Public  | What it does                                             |
| --------------------- | -------------- | -------------- | ------- | -------------------------------------------------------- |
| `share.link.create`   | `share_export` | `shares:write` | no      | Mint a password-protected link to an item or collection. |
| `share.link.revoke`   | `destructive`  | `shares:write` | no      | Kill a link immediately and permanently.                 |
| `share.link.validate` | `read`         | `shares:read`  | **yes** | Redeem a token + password, return the shared target.     |
| `share.tile.render`   | `read`         | `shares:read`  | **yes** | Render a map tile for a shared item.                     |
| `share.tilejson`      | `read`         | `shares:read`  | **yes** | TileJSON document to initialise a shared map.            |

The first two are authenticated, owner-side operations: only an **editor or admin** on
the target's project (or a service-role principal) may create or revoke a share. The
last three are the redemption side — they are **public operations** that run without a
Bearer token, authenticated instead by the link's own token and password. See
[Authentication](/api-reference/authentication) and [Scopes](/api-reference/scopes).

## Creating a share

`share.link.create` is a `share_export` operation: it causes data to leave the platform,
so it is one of the tiers that runs the full export pipeline and is recorded in
[provenance](/api-reference/guides/provenance) as a `share_link` artifact derived from
the item or collection it exports. Crucially, **the response contains a `token` that is
only ever returned here** — it is the secret that lets an anonymous viewer redeem the
link, and it is never echoed back by `validate`, `tile`, or any list/get path. Capture
it when you create the share; if you lose it, revoke the link and create a new one.

### Request body

| Field         | Type                       | Required | Notes                                                                        |
| ------------- | -------------------------- | -------- | ---------------------------------------------------------------------------- |
| `target_type` | `"item"` \| `"collection"` | yes      | What kind of thing you are sharing.                                          |
| `target_id`   | string (uuid)              | yes      | The item or collection id. Must exist and not be soft-deleted.               |
| `permission`  | `"view"` \| `"download"`   | no       | Defaults to `view`.                                                          |
| `password`    | string, **min 8 chars**    | yes      | Plaintext; the platform bcrypt-hashes it. Never stored or returned in clear. |
| `expires_at`  | RFC 3339 timestamp         | yes      | When the link stops validating. There is no default — you must set it.       |

The `project_id` and `organization_id` of the share are resolved server-side from the
target — they are **never read from your body**. If the target doesn't exist (or is
soft-deleted) you get a `404`; if you aren't an editor/admin on its project you get a
`403`.

### Request and response

```http
POST /v1/op/share.link.create HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "target_type": "item",
  "target_id": "9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44",
  "permission": "view",
  "password": "correct-horse-battery",
  "expires_at": "2026-07-20T00:00:00Z"
}
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "1f2e3d4c-5b6a-4789-8c0d-112233445566",
  "token": "k3y8Qd1m_Z2pH7sV-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789AbCd",
  "target_type": "item",
  "target_id": "9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44",
  "permission": "view",
  "organization_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "project_id": "7766554433221100-aabb-ccdd-eeff-001122334455",
  "expires_at": "2026-07-20T00:00:00Z",
  "revoked_at": null,
  "access_count": 0,
  "last_accessed_at": null,
  "created_by": "55667788-99aa-bbcc-ddee-ff0011223344",
  "created_at": "2026-06-20T12:00:00Z"
}
```

The response deliberately omits the password hash — only the safe fields above ever
cross the wire. `created_by` is the human user id of the creator; it is `null` when the
share is created by an API key or a service-role principal.

### Python

```python
from geopera import Client

client = Client(api_key="gpra_your_api_key")

share = client.op(
    "share.link.create",
    {
        "target_type": "item",
        "target_id": "9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44",
        "permission": "view",
        "password": "correct-horse-battery",
        "expires_at": "2026-07-20T00:00:00Z",
    },
)

# The token is returned exactly once — persist it now.
print(share["id"], share["token"])
```

### TypeScript

```typescript
import { Client } from '@geopera/sdk';

const client = new Client({ apiKey: 'gpra_your_api_key' });

const share = await client.op('share.link.create', {
	target_type: 'item',
	target_id: '9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44',
	permission: 'view',
	password: 'correct-horse-battery',
	expires_at: '2026-07-20T00:00:00Z'
});

// `share.token` is only present on the create response — store it now.
console.log(share.id, share.token);
```

## Scoping and permissions

A share is the narrowest possible export: it grants access to **one target and nothing
else**. There are two dimensions to control.

**Target scope.** `target_type` plus `target_id` pin the share to a single item or a
single collection. A collection share exposes the collection record; an item share
additionally enables tile rendering (see below). A viewer who redeems the link can reach
that target and nothing adjacent — not the project, not sibling items, not your other
collections.

**Permission level.** `permission` is one of:

| Value      | Meaning                                                              |
| ---------- | -------------------------------------------------------------------- |
| `view`     | The viewer may see and render the target (tiles, metadata). Default. |
| `download` | View, plus permission to pull the underlying assets.                 |

Permission is recorded on the link and returned by `share.link.validate`, so the
client redeeming the link can adapt its UI (for example, hide a download button when the
permission is `view`).

## Expiry rules

Every share **must** carry an `expires_at` — there is no open-ended share. The timestamp
is checked on every redemption: `share.link.validate`, `share.tile.render`, and
`share.tilejson` all compare `expires_at` against the current time and return `401`
(`Invalid or expired share link`) once it has passed. An expired link is not deleted; it
simply stops validating. Pick the shortest window that fits the use case.

## Validating (redeeming) a link

`share.link.validate` is the kernel's public redemption op. It runs **without a Bearer
token** — the viewer is anonymous. The token in the body both authenticates the request
and tells the platform which sharer's organization to attribute any resulting usage to,
so public access is billed to the **sharer**, never to the anonymous viewer.

Validation checks four things in order: the token resolves to a non-revoked link, the
link has not expired, the password matches (bcrypt), and the shared target still exists
and is not soft-deleted. On success it bumps `access_count`, stamps `last_accessed_at`,
and returns the target.

Password attempts are rate-limited **per token** — 10 attempts per 60-second window —
to blunt brute-force guessing regardless of source IP. Exceeding that returns `429`. A
wrong password, an expired link, or a revoked link all return a uniform `401` so a
caller can't distinguish "wrong password" from "no such link."

```http
POST /v1/op/share.link.validate HTTP/1.1
Host: api.geopera.com
Content-Type: application/json

{
  "token": "k3y8Qd1m_Z2pH7sV-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789AbCd",
  "password": "correct-horse-battery"
}
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "permission": "view",
  "target_type": "item",
  "target": {
    "id": "9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44",
    "collection_id": "…",
    "datetime": "2026-05-02T10:14:00Z",
    "bbox": [150.1, -33.9, 150.3, -33.7],
    "properties": { "…": "…" }
  }
}
```

Note that no `token`, `password_hash`, or `organization_id` appears in the validate
response — redemption never re-exposes the secret.

### Validating from a client

Because `validate` is public, the redeeming client does not send an `Authorization`
header at all. In the SDKs you can call it on a client constructed without a key:

```python
from geopera import Client

# No api_key — share.link.validate is a public operation.
public = Client()

result = public.op(
    "share.link.validate",
    {
        "token": "k3y8Qd1m_Z2pH7sV-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789AbCd",
        "password": "correct-horse-battery",
    },
)
print(result["permission"], result["target_type"])
```

```typescript
import { Client } from '@geopera/sdk';

// No apiKey — share.link.validate is public.
const pub = new Client();

const result = await pub.op('share.link.validate', {
	token: 'k3y8Qd1m_Z2pH7sV-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789AbCd',
	password: 'correct-horse-battery'
});
console.log(result.permission, result.target_type);
```

## Rendering shared tiles

For **item** shares, two further public operations let a map client draw the imagery
without an account:

- `share.tilejson` — returns a TileJSON `3.0.0` document (tile URL template, zoom range,
  resolved render profile) to initialise the map. Takes `token`, `password`, and an
  optional `profile`.
- `share.tile.render` — renders a single `z/x/y` tile as `png` (default), `webp`, or
  `jpeg`. Takes `token`, `password`, `z`, `x`, `y`, and optional `format` / `profile`.

Both re-validate the token, password, expiry, and revocation on every call — the share
operation is the authority on access, not the surrounding map route. They also re-apply
the lifecycle gate, so a shared item that has been soft-deleted or aged past retention
returns `404` even while the link itself is otherwise valid. Tile usage is billed to the
sharer's organization. Tile operations only apply to item shares; calling them against a
collection share returns `400`.

## Revoking a share

`share.link.revoke` is a `destructive` operation — it sets `revoked_at` and the link
stops validating immediately and permanently. There is no un-revoke; create a new share
if you need access again. Like create, it requires editor/admin on the target's project.

```http
POST /v1/op/share.link.revoke HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "link_id": "1f2e3d4c-5b6a-4789-8c0d-112233445566"
}
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "id": "1f2e3d4c-5b6a-4789-8c0d-112233445566"
}
```

Revoking takes the share's `link_id` (the `id` from the create response), **not** the
token. If the id doesn't resolve to a live link you get a `404`. Revoking an
already-revoked link is also `404`, so revoke is effectively idempotent on the "link is
dead" outcome.

```python
client.op("share.link.revoke", {"link_id": "1f2e3d4c-5b6a-4789-8c0d-112233445566"})
```

```typescript
await client.op('share.link.revoke', {
	link_id: '1f2e3d4c-5b6a-4789-8c0d-112233445566'
});
```

## Worked example: share an item, redeem it, revoke it

This walks the full lifecycle from the sharer's side, then the viewer's side.

```python
from geopera import Client

owner = Client(api_key="gpra_your_api_key")

# 1. Create a 30-day, view-only share for one item.
share = owner.op(
    "share.link.create",
    {
        "target_type": "item",
        "target_id": "9c4f0e2a-2b7a-4f1e-9b3d-2c1a8f6e0d44",
        "permission": "view",
        "password": "correct-horse-battery",
        "expires_at": "2026-07-20T00:00:00Z",
    },
)
token = share["token"]      # returned ONCE — keep it
link_id = share["id"]       # use this to revoke later

# 2. The viewer redeems it anonymously (no API key).
viewer = Client()
view = viewer.op(
    "share.link.validate",
    {"token": token, "password": "correct-horse-battery"},
)
assert view["permission"] == "view"
item = view["target"]

# 3. The viewer initialises a map and pulls a tile (public ops, sharer-billed).
tilejson = viewer.op(
    "share.tilejson",
    {"token": token, "password": "correct-horse-battery"},
)

# 4. When the share is no longer needed, the owner revokes it.
owner.op("share.link.revoke", {"link_id": link_id})

# 5. Redemption now fails closed with a 401.
try:
    viewer.op(
        "share.link.validate",
        {"token": token, "password": "correct-horse-battery"},
    )
except Exception as exc:
    print("link is dead:", exc)
```

## Gotchas

- **The token is shown once.** Only `share.link.create` returns `token`. Persist it
  immediately; the only recovery is to revoke and re-create.
- **Passwords are mandatory and minimum 8 characters.** A share with a weaker password
  is rejected at validation of the request body. The password is bcrypt-hashed and never
  returned.
- **`expires_at` is required.** There is no open-ended share and no implicit default.
- **Revoke takes the `link_id`, validate takes the `token`.** They are different
  identifiers; don't swap them.
- **Create/revoke are owner-only.** You need an editor or admin role on the target's
  project; an API key or service principal with no project membership is rejected with
  `403`.
- **Redemption is uniform on failure.** Wrong password, expired, and revoked all return
  `401` so the link can't be probed.
- **Usage is billed to the sharer.** Anonymous tile and validate traffic is attributed
  to the share owner's organization — keep windows short and revoke promptly.

## Related

- [Authentication](/api-reference/authentication) — Bearer tokens vs. public operations.
- [Scopes](/api-reference/scopes) — `shares:read` and `shares:write`.
- [Errors](/api-reference/errors) — the `problem+json` shape of `401`/`403`/`404`/`429`.
- [Provenance & lineage](/api-reference/guides/provenance) — the `share_link` artifact.
- [Operations](/api-reference/operations) — the `POST /v1/op/{id}` calling convention.
