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.
| 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 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
| 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
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/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
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
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.”
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/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:
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"])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 TileJSON3.0.0document (tile URL template, zoom range, resolved render profile) to initialise the map. Takestoken,password, and an optionalprofile.share.tile.render— renders a singlez/x/ytile aspng(default),webp, orjpeg. Takestoken,password,z,x,y, and optionalformat/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.
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/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.
client.op("share.link.revoke", {"link_id": "1f2e3d4c-5b6a-4789-8c0d-112233445566"})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.
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.createreturnstoken. 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_atis required. There is no open-ended share and no implicit default.- Revoke takes the
link_id, validate takes thetoken. 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
401so 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 — Bearer tokens vs. public operations.
- Scopes —
shares:readandshares:write. - Errors — the
problem+jsonshape of401/403/404/429. - Provenance & lineage — the
share_linkartifact. - Operations — the
POST /v1/op/{id}calling convention.