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 for the calling convention.

OperationSide effectScopePublicWhat it does
share.link.createshare_exportshares:writenoMint a password-protected link to an item or collection.
share.link.revokedestructiveshares:writenoKill a link immediately and permanently.
share.link.validatereadshares:readyesRedeem a token + password, return the shared target.
share.tile.renderreadshares:readyesRender a map tile for a shared item.
share.tilejsonreadshares:readyesTileJSON 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 and 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 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

FieldTypeRequiredNotes
target_type"item" | "collection"yesWhat kind of thing you are sharing.
target_idstring (uuid)yesThe item or collection id. Must exist and not be soft-deleted.
permission"view" | "download"noDefaults to view.
passwordstring, min 8 charsyesPlaintext; the platform bcrypt-hashes it. Never stored or returned in clear.
expires_atRFC 3339 timestampyesWhen 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:

ValueMeaning
viewThe viewer may see and render the target (tiles, metadata). Default.
downloadView, 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