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:

Invite one personbash
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.

Authenticated requestbash
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.

429 responsehttp
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." } }
StatusCodeMeaning
401missing_api_keyNo Authorization header was sent.
401invalid_authorization_headerThe header is not Bearer <key>.
401invalid_api_keyThe key does not match any active key.
401revoked_api_keyThe key exists but has been revoked.
403insufficient_scopeThe key does not have the scope the endpoint requires.
429rate_limitedToo many requests; see the Retry-After header.
400invalid_bodyThe request body failed validation or was not valid JSON.
400invalid_queryA query parameter failed validation.
400invalid_statusThe status filter is not one of the recognized member statuses.
400invalid_limitThe limit query parameter is not an integer between 1 and 100.
400invalid_cursorThe cursor is not the id of a member in your organization.
404not_foundThe organization, member, or resource does not exist.
404pfp_not_readyThe member has no branded profile picture yet — there's no template or render.
409not_invitedA reminder was requested for a member who is not currently awaiting an invite.
409limitThe member has already hit its reminder cap.
500internal_errorAn unexpected error. Safe to retry.

Endpoints

EndpointScopeSummary
GET/orgreadFetch the organization profile.
GET/creditsreadFetch the organization's credit balance.
GET/membersreadList members, optionally filtered by status, paginated by cursor.
POST/memberswriteCreate and invite up to 50 members.
GET/members/{memberId}readFetch a single member.
POST/members/{memberId}/remindwriteSend a reminder email to a member.
POST/members/{memberId}/retrywriteGrant the member a retry credit.
GET/members/{memberId}/imagesreadList a member's gallery images with 1 hour signed URLs.
GET/members/{memberId}/pfpreadFetch a member's branded profile picture.
POST/invites/open-linkwriteCreate 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.

Requestbash
curl "https://starkie.ai/api/teams/v1/org" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
Responsejson
{
  "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.

Requestbash
curl "https://starkie.ai/api/teams/v1/credits" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
Responsejson
{ "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.

Requestbash
curl "https://starkie.ai/api/teams/v1/members?status=ready&limit=2" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
200 OKjson
{
  "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.

Request bodyjson
{
  "people": [
    { "email": "anna@acme.com", "firstName": "Anna", "lastName": "Ross", "jobTitle": "Engineer" }
  ],
  "enforceDomains": false
}
201 Createdjson
{
  "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.

Requestbash
curl "https://starkie.ai/api/teams/v1/members/{memberId}" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
200 OKjson
{
  "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.

Requestbash
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.

Requestbash
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.

Requestbash
curl "https://starkie.ai/api/teams/v1/members/{memberId}/images" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
200 OKjson
{
  "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.

Requestbash
curl "https://starkie.ai/api/teams/v1/members/{memberId}/pfp" \
  -H "Authorization: Bearer sk_teams_XXXXXXXX_..."
200 OKjson
{
  "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.

Request bodyjson
{ "maxUses": 100, "expiresInDays": 14 }
201 Createdjson
{
  "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

EventFires when
member.invitedA person was added and invited to upload selfies.
member.uploadedA person uploaded their selfies and training has started.
member.photos_readyA person's headshots finished generating and are ready for review or download.
member.approvedA person's headshots were approved (requireApproval orgs) or auto-approved.
member.deletedA person and their photos were removed from the organization.
template.rerenderedThe 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

Delivery requesthttp
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-SignatureHMAC signature — see Verifying signatures.
X-Starkie-EventThe event type, e.g. member.approved.
X-Starkie-DeliveryA 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:

verify.jsjavascript
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.