# Keys API

For platforms that put a map on many client sites: create a key for each site from your own systems, lock it to that site's domains, give it its own monthly cap, rotate or revoke it, read usage per site, and get signed webhooks. Everything here is also in the [OpenAPI spec](https://pinsharing.com/openapi.json).

Platform plan.

The keys API, API tokens, per-key caps and webhooks come with a platform contract (up to 5,000 keys). On other plans these endpoints answer 403

plan_feature_unavailable

;

ask about a platform pilot

. Usage by key and the CSV in the dashboard are on every plan.

Two kinds of credential.

Map keys (

ps_…

) load maps, images and search on your clients' sites. API tokens (

psm_…

) manage those keys from your server. A map key never works on the keys API, and an API token never loads a map. Make API tokens in your

dashboard

under API tokens: up to 10 per account, each with the access it needs.

## Access

| Scope | Allows |
| --- | --- |
| `keys:read` | List and read keys and webhooks |
| `keys:write` | Create, change, rotate and revoke keys; add and delete webhooks |
| `usage:read` | Usage per key, per day or month, as JSON or CSV |

Send the token as `Authorization: Bearer psm_…`, from your server only. On accounts with two-step sign-in, making or revoking a token asks you to confirm it's you, and every change is emailed to the account owner.

## Create a key

```bash
curl -X POST https://api.pinsharing.com/v1/keys \
  -H "Authorization: Bearer psm_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: site-48213" \
  -d '{"name": "Smith Realty", "referers": ["smithrealty.com", "*.smithrealty.com"],
       "external_id": "site-48213", "caps": {"tiles": 500000}, "metadata": {"plan": "pro"}}'
```

```
HTTP/1.1 201 Created
{
  "id": "key_3f9c2a7d1b8e4c60a5d2",
  "key": "ps_…",                     // shown in this response only
  "prefix": "ps_7Hq2xR",
  "name": "Smith Realty",
  "referers": ["smithrealty.com", "*.smithrealty.com"],
  "external_id": "site-48213",
  "caps": { "static": null, "geocode": null, "tiles": 500000 },
  "metadata": { "plan": "pro" },
  "created_via": "api",
  "created_at": "2026-11-06T15:04:05Z",
  "revoked_at": null,
  "last_used_at": null,
  "usage_this_month": { "static": 0, "geocode": 0, "tiles": 0 }
}
```

- **`referers`**: the site's host names; `*.example.com` also covers subdomains. Leave it out only for keys that stay on a server.
- **`external_id`**: your own id for the site (up to 128 characters), unique among the account's live keys. Look keys up by it.
- **`caps`**: optional monthly caps for this key, per product (`static`, `geocode`, `tiles`). A product left out shares the account's cap. A key's cap can't be above the plan's cap and never raises it.
- **`metadata`**: any JSON object up to 4 KB, returned as is.
- **`Idempotency-Key`**: retrying with the same header and body within 24 hours returns the same key (with `Idempotent-Replayed: true`) instead of making a second one. The same header with a different body is refused with 422.

## Find, change, rotate and revoke

```
# find a site's key, change it, rotate it, revoke it
curl "https://api.pinsharing.com/v1/keys?external_id=site-48213" -H "Authorization: Bearer psm_YOUR_TOKEN"
curl -X PATCH https://api.pinsharing.com/v1/keys/key_3f9c2a7d1b8e4c60a5d2 -H "Authorization: Bearer psm_YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{"referers": ["smithrealty.com", "smith.homes"], "caps": {"tiles": 750000}}'
curl -X POST https://api.pinsharing.com/v1/keys/key_3f9c2a7d1b8e4c60a5d2/rotate -H "Authorization: Bearer psm_YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{"grace_seconds": 3600}'
curl -X DELETE https://api.pinsharing.com/v1/keys/key_3f9c2a7d1b8e4c60a5d2 -H "Authorization: Bearer psm_YOUR_TOKEN"
```

- `GET /v1/keys` lists live keys 100 at a time (`limit` up to 200); pass `next_cursor` back as `cursor` for the next page. Add `status=all` to include revoked keys.
- `PATCH` changes only the fields you send. `caps` replaces all three caps; `"external_id": null` clears it.
- **Rotate** gives the key a new secret and keeps its id, caps and usage. The old secret keeps working for `grace_seconds` (default 3,600, up to 86,400; 0 ends it at once) until `previous_key_valid_until`, so you can update the site first.
- Ids are opaque strings (`key_…`, `whk_…`, `evt_…`); store them as text. Unknown fields in a request body are refused with 400.
- **Revoke** stops the key everywhere within about a minute. Keys can be created or rotated at up to 10 a second and 300 a minute per account.

## Caps per key

A request is checked against the key's own cap first, then the account's. When a key reaches its cap, that key gets HTTP 429 with `"error": "monthly_cap_reached"` and `"scope": "key"` until the first of next month; your other keys keep working. When the account reaches a plan cap, the answer carries `"scope": "account"`. Nothing is ever billed for overage.

## Usage

```bash
curl "https://api.pinsharing.com/v1/keys/key_3f9c2a7d1b8e4c60a5d2/usage?granularity=day&from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer psm_YOUR_TOKEN"
curl "https://api.pinsharing.com/v1/usage?month=2026-11&group_by=key" -H "Authorization: Bearer psm_YOUR_TOKEN"
curl -o usage.csv "https://api.pinsharing.com/v1/usage?month=2026-11&group_by=key&format=csv" -H "Authorization: Bearer psm_YOUR_TOKEN"
```

Per key, `granularity=day` covers up to 400 days (the last 13 months are kept) and `granularity=month` any range of months. The account report lists every key that was live or used in the month, with an account total row in the CSV. Map tiles served from our CDN count the same as any other request. Per-key counts are written every 30 seconds and `as_of` says when they last were; a key's `usage_this_month` is always up to the moment.

You can also download this month's CSV from the dashboard.

## Webhooks

```bash
curl -X POST https://api.pinsharing.com/v1/webhooks -H "Authorization: Bearer psm_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/pinsharing", "events": ["key.usage.80", "key.usage.100", "key.created", "key.revoked"]}'

# check it, see what was sent, resend one, turn it off or back on
curl -X POST https://api.pinsharing.com/v1/webhooks/whk_8b1e0c44f2a95d73e610/test -H "Authorization: Bearer psm_YOUR_TOKEN"
curl "https://api.pinsharing.com/v1/webhooks/whk_8b1e0c44f2a95d73e610/deliveries?status=failed" -H "Authorization: Bearer psm_YOUR_TOKEN"
curl -X POST https://api.pinsharing.com/v1/webhooks/whk_8b1e0c44f2a95d73e610/deliveries/evt_…/redeliver -H "Authorization: Bearer psm_YOUR_TOKEN"
curl -X PATCH https://api.pinsharing.com/v1/webhooks/whk_8b1e0c44f2a95d73e610 -H "Authorization: Bearer psm_YOUR_TOKEN"   -H "Content-Type: application/json" -d '{"enabled": true}'
```

The answer includes the webhook's signing `secret`, shown once. Events:

| Event | When |
| --- | --- |
| `key.usage.80`, `key.usage.100` | A key reaches 80% or 100% of its own cap for a product (once per key, product and month) |
| `account.usage.80`, `account.usage.100` | The account reaches 80% or 100% of a plan cap (the same moments as the usage emails) |
| `key.created`, `key.rotated`, `key.revoked` | A key is created, rotated or revoked, through the API or the dashboard (`data.actor` says which) |
| `webhook.test` | Only when you call `POST /v1/webhooks/{id}/test` |

```
POST /pinsharing HTTP/1.1
Content-Type: application/json
PS-Signature: t=1793990000,v1=5d1c…

{
  "id": "evt_…",
  "type": "key.usage.80",
  "created": 1793990000,
  "account": "acct_51c0e2b9a7f34d18c6e0",
  "data": { "key": { "id": "key_3f9c2a7d1b8e4c60a5d2", "name": "Smith Realty", "external_id": "site-48213" },
            "product": "tiles", "level": 80, "used": 400000, "cap": 500000, "month": "2026-11", "resets": "2026-12-01" }
}
```

- Up to 5 webhooks per account, on `https` URLs with a host name (no IP addresses, port 443) that resolves to public addresses only. Every delivery connects to the address it checked. Redirects aren't followed.
- Answer with any 2xx within 10 seconds. Anything else is retried with growing gaps (30 seconds up to an hour) for 24 hours, then marked failed. At most two deliveries to one webhook are in flight at a time.
- A webhook that answers `410 Gone`, or fails every delivery for 3 days, is turned off and the account owner gets an email. Turn it back on with `PATCH` and `{"enabled": true}`.
- `GET /v1/webhooks` shows each webhook's status and latest delivery; `/deliveries` lists the last 30 days, and `/redeliver` sends one again with the same event id.
- Deliveries can arrive more than once or out of order: use the event `id` to ignore repeats.

### Check the signature

`PS-Signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256>`, computed over `<t>.<raw body>` with the webhook's secret. Compare in constant time and reject old timestamps.

**Node.js**

```
import crypto from "node:crypto";

// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing.
export function verifyPinSharing(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 || "", "hex");
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}
```

**Python**

```
import hashlib, hmac, time

def verify_pinsharing(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """raw_body: the request body exactly as received, before any JSON parsing."""
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts.get("t", "0"))
    except ValueError:
        return False
    if abs(time.time() - t) > tolerance:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))
```

## Errors

| Status | `error` | Meaning |
| --- | --- | --- |
| 401 | `missing_token`, `invalid_token` | No API token, a map key instead of a token, or a revoked token |
| 403 | `insufficient_scope`, `plan_feature_unavailable` | The token lacks the scope this call needs, or the account isn't on the platform plan |
| 404 | `not_found` | No such key or webhook on this account (or it's already revoked) |
| 409 | `key_limit_reached`, `external_id_taken`, `webhook_limit_reached`, `idempotency_in_progress` | The plan's key limit (Free 1, trial 3, paid plans 20, platform contracts 5,000), a duplicate `external_id`, 5 webhooks already, or the same Idempotency-Key still running |
| 422 | `idempotency_key_reused` | An Idempotency-Key reused with a different body |
| 429 | `slow_down` | Over the key creation rate; retry after a second |

---
Source: https://pinsharing.com/docs/keys-api · Map data © OpenStreetMap contributors · Index: https://pinsharing.com/llms.txt
