Developers
Starkie Teams API
A REST API and signed webhooks for managing your organization's headshots programmatically: create members from your own systems, check progress, and react to events without polling.
Overview
Everything an organization's admins can do from Settings → API — invite people, check who has uploaded, fetch finished galleries and branded profile pictures — is available over HTTPS with a bearer API key. Responses are JSON in a { data } envelope; errors are { error: { code, message } }.
Quickstart
Create a key with the write scope from Settings → API, then invite your first person:
curl -X POST "https://starkie.ai/api/teams/v1/members" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..." \
-H "Content-Type: application/json" \
-d '{"people":[{"email":"anna@acme.com","firstName":"Anna","lastName":"Ross"}]}'Register a webhook for member.photos_ready and you will be told when Anna's 40 headshots are done; fetch them with GET /members/{memberId}/images.
Base URL and versioning
Every path on this page is relative to https://starkie.ai.
The API is versioned in the path. v1 is stable and changes additively only: new endpoints, new optional request fields and new response fields can appear without notice, so parse defensively and ignore fields you do not recognize. Anything that would remove or repurpose an existing field ships as /v2 instead, and the same rule covers the webhook envelope — a new event type can be added to an existing subscription set, so switch on type rather than assuming the list is closed.
Authentication
Every request carries a bearer API key created from your organization's Settings → API page: sk_teams_<prefix>_<secret>. The full key is shown once, at creation time — only a SHA-256 hash of the key is stored.
curl "https://starkie.ai/api/teams/v1/org" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."Each key has one or more scopes: read for GET endpoints, and write for POST endpoints (a write key can also call read endpoints). A request against an endpoint the key isn't scoped for gets a 403 insufficient_scope.
An organization can hold up to 10 active keys. Revoking a key keeps it in the audit trail and frees its slot.
Rate limits
120 requests per minute per key, on a sliding window. A request over the limit gets a 429 rate_limited response with a Retry-After header (seconds until the next slot opens). Every successful response also carries X-RateLimit-Remaining.
HTTP/1.1 429 Too Many Requests
Retry-After: 17
Content-Type: application/json
{ "error": { "code": "rate_limited", "message": "Too many requests." } }The limit is tracked per application instance, not shared across a cluster — it's a best-effort guard against runaway callers, not a precise global quota.
Errors
Every error response has the same shape:
{ "error": { "code": "insufficient_scope", "message": "This API key does not have the \"write\" scope." } }| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No Authorization header was sent. |
| 401 | invalid_authorization_header | The header is not Bearer <key>. |
| 401 | invalid_api_key | The key does not match any active key. |
| 401 | revoked_api_key | The key exists but has been revoked. |
| 403 | insufficient_scope | The key does not have the scope the endpoint requires. |
| 429 | rate_limited | Too many requests; see the Retry-After header. |
| 400 | invalid_body | The request body failed validation or was not valid JSON. |
| 400 | invalid_query | A query parameter failed validation. |
| 400 | invalid_status | The status filter is not one of the recognized member statuses. |
| 400 | invalid_limit | The limit query parameter is not an integer between 1 and 100. |
| 400 | invalid_cursor | The cursor is not the id of a member in your organization. |
| 404 | not_found | The organization, member, or resource does not exist. |
| 404 | pfp_not_ready | The member has no branded profile picture yet — there's no template or render. |
| 409 | not_invited | A reminder was requested for a member who is not currently awaiting an invite. |
| 409 | limit | The member has already hit its reminder cap. |
| 500 | internal_error | An unexpected error. Safe to retry. |
Endpoints
| Endpoint | Scope | Summary |
|---|---|---|
| GET/org | read | Fetch the organization profile. |
| GET/credits | read | Fetch the organization's credit balance. |
| GET/members | read | List members, optionally filtered by status, paginated by cursor. |
| POST/members | write | Create and invite up to 50 members. |
| GET/members/{memberId} | read | Fetch a single member. |
| POST/members/{memberId}/remind | write | Send a reminder email to a member. |
| POST/members/{memberId}/retry | write | Grant the member a retry credit. |
| GET/members/{memberId}/images | read | List a member's gallery images with 1 hour signed URLs. |
| GET/members/{memberId}/pfp | read | Fetch a member's branded profile picture. |
| POST/invites/open-link | write | Create an open (multi-use) invite link for the organization. |
GET/api/teams/v1/org
Scope: read
The organization the key belongs to: name, slug, locale, country, retention period and whether approval is required before photos are released.
curl "https://starkie.ai/api/teams/v1/org" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{
"data": {
"id": "org_...",
"name": "Acme",
"slug": "acme",
"locale": "en",
"country": "IT",
"retentionDays": 30,
"requireApproval": true
}
}GET/api/teams/v1/credits
Scope: read
The number of credits the organization can spend. One credit is one person's 40 headshots.
curl "https://starkie.ai/api/teams/v1/credits" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{ "data": { "balance": 42 } }GET/api/teams/v1/members
Scope: read
Lists members, optionally filtered by status and paginated with limit (1–100, default 50) and cursor. nextCursor sits alongside data at the top level of the response, not nested inside it; pass it back as the next request's cursor and stop once it comes back null.
curl "https://starkie.ai/api/teams/v1/members?status=ready&limit=2" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{
"data": [
{ "id": "cle...1", "email": "anna@acme.com", "status": "ready", "...": "..." },
{ "id": "cle...2", "email": "ben@acme.com", "status": "ready", "...": "..." }
],
"nextCursor": "cle...2"
}status must be one of invited, uploaded, training, generating, ready, selected, approved, rejected, failed, deleted. A cursor that isn't the id of a member in your organization gets a 400 invalid_cursor.
POST/api/teams/v1/members
Scope: write
Creates and invites up to 50 people per call. Each one receives the invite email in the organization's locale and a private gallery link.
{
"people": [
{ "email": "anna@acme.com", "firstName": "Anna", "lastName": "Ross", "jobTitle": "Engineer" }
],
"enforceDomains": false
}{
"data": {
"created": [
{
"id": "cle...",
"email": "anna@acme.com",
"firstName": "Anna",
"lastName": "Ross",
"jobTitle": "Engineer",
"status": "invited",
"invitedAt": "2026-09-05T12:00:00.000Z",
"uploadedAt": null,
"readyAt": null,
"approvedAt": null,
"retryCredits": 0,
"galleryUrl": "https://starkie.ai/teams/m/<token>"
}
],
"skipped": []
}
}galleryUrl is only included for a key with the write scope — it's a bearer credential in its own right. Each skipped entry carries a reason: exists (already a member), domain (rejected by enforceDomains), duplicate_in_request (the same email, case-insensitively, more than once in this call — only the first is created), or error.
GET/api/teams/v1/members/{memberId}
Scope: read
One member by id. Members of other organizations answer 404 not_found, never a 403 that would reveal they exist.
curl "https://starkie.ai/api/teams/v1/members/{memberId}" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{
"data": {
"id": "cle...",
"email": "anna@acme.com",
"firstName": "Anna",
"lastName": "Ross",
"jobTitle": "Engineer",
"status": "ready",
"invitedAt": "2026-09-05T12:00:00.000Z",
"uploadedAt": "2026-09-05T12:20:00.000Z",
"readyAt": "2026-09-05T13:05:00.000Z",
"approvedAt": null,
"retryCredits": 0,
"galleryUrl": "https://starkie.ai/teams/m/<token>"
}
}galleryUrl is present only for a write-scoped key.
POST/api/teams/v1/members/{memberId}/remind
Scope: write
Sends the invite reminder email to a member who has not uploaded yet. Answers with the member; 409 not_invited if they are past that stage and 409 limit once the reminder cap is reached.
curl -X POST "https://starkie.ai/api/teams/v1/members/{memberId}/remind" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."POST/api/teams/v1/members/{memberId}/retry
Scope: write
Grants the member one retry credit, so they can upload again and regenerate at no cost to their credit. Answers with the updated member.
curl -X POST "https://starkie.ai/api/teams/v1/members/{memberId}/retry" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."GET/api/teams/v1/members/{memberId}/images
Scope: read
The member's finished gallery, as signed URLs valid for one hour. selectedImageId is the frame the member chose for their profile picture, if any.
curl "https://starkie.ai/api/teams/v1/members/{memberId}/images" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{
"data": {
"images": [
{ "id": "img_1", "webUrl": "https://...", "squareUrl": "https://...", "isSelected": true }
],
"selectedImageId": "img_1"
}
}GET/api/teams/v1/members/{memberId}/pfp
Scope: read
The member's branded profile picture — the organization's template applied to their selected frame — at two sizes, plus the template version it was rendered with. 404 pfp_not_ready until there is a template and a render.
curl "https://starkie.ai/api/teams/v1/members/{memberId}/pfp" \
-H "Authorization: Bearer sk_teams_XXXXXXXX_..."{
"data": {
"url1024": "https://...",
"url512": "https://...",
"version": 3
}
}POST/api/teams/v1/invites/open-link
Scope: write
Creates an open, multi-use invite link for the organization. Both fields are optional: omit maxUses for unlimited, omit expiresInDays for a link that does not expire.
{ "maxUses": 100, "expiresInDays": 14 }{
"data": {
"token": "...",
"url": "https://starkie.ai/teams/join/<token>",
"expiresAt": "2026-09-19T12:00:00.000Z",
"maxUses": 100
}
}Webhooks
Add an endpoint from Settings → API and pick the events you want. Every matching event is POSTed to every active, subscribed endpoint of your organization. Only https:// endpoints are accepted, and targets resolving to localhost, private networks, or link-local/cloud-metadata addresses are rejected outright.
Events
| Event | Fires when |
|---|---|
| member.invited | A person was added and invited to upload selfies. |
| member.uploaded | A person uploaded their selfies and training has started. |
| member.photos_ready | A person's headshots finished generating and are ready for review or download. |
| member.approved | A person's headshots were approved (requireApproval orgs) or auto-approved. |
| member.deleted | A person and their photos were removed from the organization. |
| template.rerendered | The org's branded profile-picture template changed and re-render jobs were enqueued for the template version. |
Envelope
Every delivery's body is a JSON envelope, regardless of event type:
{
"id": "evt_AbCdEf1234567890abcdefgh",
"type": "member.photos_ready",
"createdAt": "2026-09-05T12:00:00.000Z",
"organizationId": "org_...",
"data": { "...": "event-specific payload" }
}Headers
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Starkie-Signature: t=1700000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Starkie-Event: member.photos_ready
X-Starkie-Delivery: dlv_...| X-Starkie-Signature | HMAC signature — see Verifying signatures. |
| X-Starkie-Event | The event type, e.g. member.approved. |
| X-Starkie-Delivery | A unique id for this delivery attempt group. |
Retries and replay
A delivery that doesn't get a 2xx response (including a redirect, a timeout, or any network error) is retried up to 5 times total, with exponential backoff between attempts (starting at 1 minute, capped at 4 hours). After 5 failed attempts the delivery is marked failed and not retried further automatically.
Delivery is at-least-once: a request can succeed on our end after a response times out or its confirmation is lost, which still counts as a failure here and is retried. Your endpoint may therefore receive the same event more than once — dedupe on the envelope's id (equivalently, the X-Starkie-Delivery header) rather than assuming exactly-once delivery.
From Settings → API, any past delivery — delivered, failed, or still pending — can be replayed with Redeliver. A replay creates a fresh delivery (its own id, attempts, and signature timestamp) carrying the same event id and payload.
An organization can register up to 10 webhook endpoints, active or not. Deleting one frees its slot.
Verifying signatures
X-Starkie-Signature looks like t=1700000000,v1=5257a869.... Verify it against the exact raw request body (before any JSON parsing) using your endpoint's secret, shown once when the webhook was created:
const crypto = require('crypto');
function verifyStarkieSignature(secret, header, rawBody, toleranceS = 300) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec((header || '').trim());
if (!match) return false;
const timestamp = Number(match[1]);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceS) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const provided = Buffer.from(match[2], 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (provided.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(provided, expectedBuf);
}Use your framework's raw-body option (e.g. Express' express.raw()) rather than a JSON-parsed body — re-serializing JSON does not reliably reproduce the exact bytes that were signed.