Keys API
On this page
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.
plan_feature_unavailable; ask about a platform pilot. Usage by key and the CSV in the dashboard are on every plan.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
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.comalso 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 (withIdempotent-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/keyslists live keys 100 at a time (limitup to 200); passnext_cursorback ascursorfor the next page. Addstatus=allto include revoked keys.PATCHchanges only the fields you send.capsreplaces all three caps;"external_id": nullclears 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) untilprevious_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
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
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
httpsURLs 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 withPATCHand{"enabled": true}. GET /v1/webhooksshows each webhook's status and latest delivery;/deliverieslists the last 30 days, and/redeliversends one again with the same event id.- Deliveries can arrive more than once or out of order: use the event
idto 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 |