Skip to content

API overview & authentication

The SafeHanded REST API lets you create requests, read their state, end them, and feed ticket status changes in from your own tooling. For: Integrators. Available on Team and above.

Create a key under Settings → API (Owner/Admin). Keys look like sh_live_ followed by 43 characters; SafeHanded stores only a hash and shows the key once. Send it as a bearer token:

Authorization: Bearer sh_live_…

Each key carries:

PropertyMeaning
Scopesrequests:read, requests:write, tickets:write, events:read, webhooks:write. A missing scope answers 403.
Client scope (MSP)Optional — a client-scoped key only sees and creates for that client.
IP allowlistOptional addresses or CIDR blocks; use from elsewhere answers 403 and is audited.
ExpiryOptional; expired or revoked keys answer 401.

Test a key with GET /ping — it returns the organisation, the key’s scopes and a connection label.

  1. Download the SafeHanded Postman collection (right-click → Save link as…), then in Postman choose Import and drop the file in. Or choose Import → Link and paste https://docs.safehanded.com/safehanded-api.postman_collection.json.
  2. Open the SafeHanded API collection → Variables and paste your key into apiKey. Every request uses it automatically.
  3. Send Connection → Check the API key. A 200 with your organisation’s name means you’re set up.

The requests are grouped like the reference (Connection, Requests, Shares, Events, Tickets, Webhook subscriptions) and come with example bodies. POSTs send a fresh Idempotency-Key each time, so a resend creates a new request rather than replaying the last one.

Prefer to generate your own client, or use Insomnia, Bruno or another tool? Import the OpenAPI 3.1 document instead — Postman accepts it too (Import → Link, https://docs.safehanded.com/openapi.json).

  • Errors are { "message", "code" }; codes include unauthenticated, forbidden, not_found, validation_failed, conflict, rate_limited, and plan_required (403) when your plan doesn’t include the API.
  • Rate limit 600 requests/minute per key, and 1,200 requests/minute across all of an organisation’s keys (X-RateLimit-*; 429 with Retry-After).
  • Idempotency — send Idempotency-Key: <unique string> with a POST; the first response is stored for 24 hours and replayed for the same key and body. A replay returns the same status and Location header, but secret fields (link, link_without_fragment, challenge_code, a share’s secret/value) come back null: they are shown once, on the original response.
  • Pagination — limit (≤ 100) and cursor (the previous page’s next_cursor); newest first.
  • Ids are 26-character ULIDs; times are ISO-8601.
Method and pathScopePurpose
GET /pinganyVerify the key
POST /generateanyGenerate a strong password/passphrase (stores nothing)
POST /requestsrequests:writeCreate and commit a request; returns the full link
GET /requestsrequests:readList (filter by status, client_id, ticket_ref)
GET /requests/{id}requests:readOne request
POST /requests/{id}/revokerequests:writeKill a live link
POST /requests/{id}/completerequests:writeDestroy a submitted credential
POST /tickets/{ref}/statustickets:writeMark a ticket status (close-to-purge)
GET /eventsevents:readPoll event objects
GET/POST/DELETE /webhookswebhooks:writeManage webhook subscriptions

The API reference has the full detail for each, including the certificate downloads.

Terminal window
curl -X POST https://safehanded.com/api/v1/requests \
-H "Authorization: Bearer sh_live_…" -H "Content-Type: application/json" \
-H "Idempotency-Key: ticket-4411-attempt-1" \
-d '{
"subject_name": "Jane Doe",
"subject_email": "jane@customer.example",
"justification": "Password reset for ticket 4411, identity confirmed by phone.",
"recipients": ["tech@msp.example"],
"ticket_ref": "HD-4411",
"ttl_minutes": 60,
"verification_mode": "challenge",
"delivery": { "method": "email" }
}'

Recipients are members of your organisation, addressed by email; each must have set up an identity key (the request is encrypted to them). The 201 response contains the request, the complete link, and the challenge_code when the verification mode needs one — give the code to the subject out of band, never in the same message as the link.

POST /api/v1/tickets/HD-4411/status
{ "status": "closed" }

Where the linked adapter can read the ticket back, the status is verified against the ticket system before anything is destroyed. Requests that merely quote the ticket reference are matched too, but those are unverified and only purge when Trust unverified webhooks is enabled in Policy.

  • Prefer one key per integration with the smallest scopes, an IP allowlist, and an expiry.
  • Every API action is audited with the key’s name; revoke a key and its callers get 401 immediately.
  • Payloads never contain envelope material, link tokens, codes or key material — enforced by a test suite over every response shape.