## Why It Matters

The Hiring API gives external systems read access to your hiring data — job boards pull published postings, HRIS systems sync candidate records, dashboards track pipeline metrics. Token scopes ensure each integration only sees what it needs.

## Base URL

All API endpoints are accessed via:

```
https://startupkit.app/api/v1
```

## Authentication

Every request requires an API token in the `Authorization` header:

```
Authorization: Bearer YOUR_TOKEN
```

Alternative format (both work):
```
Authorization: token YOUR_TOKEN
```

Create tokens at **Settings > API**. Each token must have explicit scopes to access hiring endpoints.

| Scope | Access |
|-------|--------|
| `job_postings:read` | Job postings and stages |
| `candidates:read` | Candidate PII (name, email, phone), resume downloads |
| `applications:read` | Applications, stage history, resume downloads |

Tokens without any scopes cannot access hiring endpoints.

### Token Format and Lifecycle

- **Format:** 32-character hexadecimal string (e.g., `a1b2c3d4e5f6...`)
- **Last used tracking:** Every successful request updates `last_used_at`
- **Expiration:** Optional `expires_at` can be set when creating the token
- **Inactivity alerts:** Tokens unused for 30+ days trigger admin notifications
- **Auto-revocation:** Tokens are automatically revoked when a user is removed from the account

Best practice: Create separate tokens per integration with minimal scopes needed.

## Account Scoping

Each API token is bound to a specific account. The account is derived automatically from the token — no `account_id` parameter is needed.

When a team member is removed from an account, their tokens for that account are revoked automatically.

## Error Responses

| Status | Meaning | Common Causes |
|--------|---------|---------------|
| `400` | Bad request | Account context missing (edge case in session-based auth) |
| `401` | Unauthorized | Missing, invalid, or expired token |
| `403` | Forbidden | Token lacks required scope for this endpoint |
| `404` | Not found | Resource doesn't exist or doesn't belong to your account |
| `422` | Unprocessable entity | Validation errors (primarily for non-hiring endpoints) |

## Pagination

List endpoints return paginated results:

```json
{
  "data": [...],
  "pagination": {
    "current_page": 1,
    "total_pages": 3,
    "total_count": 42,
    "per_page": 20
  }
}
```

Pass `?page=2` to fetch subsequent pages.

---

## Job Postings

**Scope required:** `job_postings:read`

### List Job Postings

```
GET /api/v1/job_postings
```

**Example:**
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://startupkit.app/api/v1/job_postings?status=published
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `status` | string | Filter by `draft`, `published`, or `closed` |
| `page` | integer | Page number |

**Response:**

```json
{
  "data": [
    {
      "id": "job_abc123",
      "title": "Senior Rails Developer",
      "status": "published",
      "department": "Engineering",
      "location": "New York, NY",
      "employment_type": "full_time",
      "remote": true,
      "salary_min": "120000.0",
      "salary_max": "180000.0",
      "salary_currency": "USD",
      "salary_period": "year",
      "published_at": "2026-01-15T10:00:00Z",
      "closed_at": null,
      "created_at": "2026-01-10T08:30:00Z",
      "updated_at": "2026-02-01T14:20:00Z",
      "stages": [
        {
          "id": "stg_def456",
          "name": "Screening",
          "stage_type": "default",
          "position": 0
        }
      ],
      "counts": {
        "total_applications": 24,
        "active_applications": 18,
        "rejected": 6
      }
    }
  ],
  "pagination": { ... }
}
```

### Get Job Posting

```
GET /api/v1/job_postings/:id
```

Returns the same shape as an item in the list response.

### List Stages

```
GET /api/v1/job_postings/:job_posting_id/stages
```

**Response:**

```json
{
  "data": [
    {
      "id": "stg_def456",
      "name": "Screening",
      "stage_type": "default",
      "position": 0
    }
  ]
}
```

Stages are ordered by position. No pagination.

---

## Candidates

**Scope required:** `candidates:read`

### List Candidates

```
GET /api/v1/candidates
```

**Example:**
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://startupkit.app/api/v1/candidates?search=john@example.com
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `search` | string | Search by name or email |
| `page` | integer | Page number |

**Response:**

```json
{
  "data": [
    {
      "id": "cand_ghi789",
      "first_name": "John",
      "last_name": "Doe",
      "email": "john@example.com",
      "phone": "+1-555-0100",
      "status": "active",
      "github_username": "johndoe",
      "created_at": "2026-01-20T09:15:00Z",
      "applications": [
        {
          "id": "app_jkl012",
          "job_posting_id": "job_abc123",
          "job_posting_title": "Senior Rails Developer",
          "current_stage_name": "Interview",
          "status": "active",
          "submitted_at": "2026-01-20T09:15:00Z"
        }
      ]
    }
  ],
  "pagination": { ... }
}
```

### Get Candidate

```
GET /api/v1/candidates/:id
```

Returns the same shape as an item in the list response.

---

## Applications

**Scope required:** `applications:read`

### List Applications

```
GET /api/v1/applications
```

**Example:**
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://startupkit.app/api/v1/applications?job_posting_id=job_abc123&status=active
```

**Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `job_posting_id` | string | Filter by job posting prefix ID |
| `status` | string | Filter by `active` or `rejected` |
| `page` | integer | Page number |

**Response:**

```json
{
  "data": [
    {
      "id": "app_jkl012",
      "job_posting_id": "job_abc123",
      "candidate_id": "cand_ghi789",
      "current_stage_name": "Interview",
      "status": "active",
      "submitted_at": "2026-01-20T09:15:00Z",
      "created_at": "2026-01-20T09:15:00Z",
      "updated_at": "2026-02-10T11:30:00Z",
      "candidate": {
        "id": "cand_ghi789",
        "first_name": "John",
        "last_name": "Doe",
        "email": "john@example.com"
      },
      "stage_history": [
        {
          "id": "sp_mno345",
          "stage_name": "Screening",
          "stage_type": "default",
          "status": "completed",
          "started_at": "2026-01-20T09:15:00Z",
          "completed_at": "2026-01-25T16:00:00Z"
        },
        {
          "id": "sp_pqr678",
          "stage_name": "Interview",
          "stage_type": "interview",
          "status": "in_progress",
          "started_at": "2026-01-25T16:00:00Z",
          "completed_at": null
        }
      ],
      "metafields": [
        {
          "key": "years_of_experience",
          "label": "Years of Experience",
          "field_type": "number",
          "value": "8",
          "source": "ai_extracted",
          "confidence": 0.92,
          "extracted_at": "2026-01-20T09:18:00Z",
          "edited": false,
          "original_value": null
        },
        {
          "key": "salary_expectation",
          "label": "Salary Expectation",
          "field_type": "currency",
          "value": "150000",
          "source": "ai_extracted",
          "confidence": 0.75,
          "extracted_at": "2026-01-20T09:18:00Z",
          "edited": true,
          "original_value": "140000"
        }
      ]
    }
  ],
  "pagination": { ... }
}
```

### Metafield Data

Each application includes a `metafields` array containing structured data fields extracted or entered for that application. Every entry has:

| Field | Type | Description |
|-------|------|-------------|
| `key` | string | Machine-readable identifier for the field |
| `label` | string | Human-readable display name |
| `field_type` | string | Data type: `text`, `number`, `currency`, `date`, `boolean`, etc. |
| `value` | string \| null | Current value (always serialized as a string) |
| `source` | string | How the value was set: `ai_extracted`, `manual`, `candidate_provided`, etc. |
| `confidence` | float \| null | AI confidence score (0.0–1.0) when `source` is `ai_extracted`; `null` otherwise |
| `extracted_at` | timestamp \| null | When the value was set or extracted |
| `edited` | boolean | `true` if a human has overridden an AI-extracted value |
| `original_value` | string \| null | The original AI-extracted value before human edits; `null` if never edited |

Fields marked `managers_only` in the job posting configuration are only included when the API token belongs to a user with manager-level access or above. Tokens belonging to reviewers or external integrations will not see those fields in the array.

### Get Application

```
GET /api/v1/applications/:id
```

Returns the same shape as an item in the list response. When the token has `candidates:read` scope and the application has a resume attached, the response includes resume metadata:

```json
{
  "id": "app_jkl012",
  "resume_filename": "john_doe_resume.pdf",
  "resume_content_type": "application/pdf",
  "resume_byte_size": 245760,
  ...
}
```

These fields are omitted when no resume is attached or when `candidates:read` scope is missing.

### Download Resume

**Scopes required:** `applications:read` + `candidates:read`

```
GET /api/v1/applications/:application_id/resume
```

Downloads the candidate's resume for the given application. Returns a `302` redirect to a time-limited signed URL (valid for 30 minutes).

**Example:**
```bash
curl -L -H "Authorization: Bearer YOUR_TOKEN" \
  -o resume.pdf \
  https://startupkit.app/api/v1/applications/app_jkl012/resume
```

**Response:**
- `302 Found` -- Redirect to signed download URL. Use `-L` (follow redirects) with curl.
- `403 Forbidden` -- Token lacks `applications:read` or `candidates:read` scope.
- `404 Not Found` -- Application not found, or no resume attached.

**Notes:**
- Signed URLs expire after 30 minutes. If expired, request a new one.
- The `Content-Disposition` header is set to `attachment` (triggers browser download).
- Resume file types: PDF, DOC, DOCX (as uploaded by the candidate).

### Candidate PII Redaction

When a token has `applications:read` but **not** `candidates:read`, candidate data in application responses is redacted to only the ID:

```json
{
  "candidate": {
    "id": "cand_ghi789"
  }
}
```

No `first_name`, `last_name`, or `email` fields are included. This lets job board integrations track application status without exposing candidate contact information.

---

## Troubleshooting

### Authentication Issues

**401 Unauthorized**

If you're getting a 401 response, check:

1. **Token format:** Ensure the token is exactly as shown in Settings > API (32-character hex string)
2. **Authorization header:** Must be `Authorization: Bearer TOKEN` or `Authorization: token TOKEN`
3. **Token expiration:** Check if `expires_at` has passed in Settings > API
4. **Account membership:** Verify the token owner is still a member of the account

**403 Forbidden**

When you get a 403, the token is valid but lacks required scope:

1. **Check required scopes:** Each endpoint documents its required scope (e.g., `job_postings:read`)
2. **Add missing scopes:** Edit the token in Settings > API and add the needed scope
3. **Scope format:** Must match exactly — `job_postings:read`, not `job_postings` or `read`

### Data Access Issues

**Empty `data` array in responses**

If the API returns `{"data": [], "pagination": {...}}` but you expect results:

1. **Account isolation:** API tokens only see data from their bound account
2. **Verify account:** Check which account the token belongs to in Settings > API
3. **Cross-account access:** Tokens cannot access data from other accounts, even if the user is a member

**Candidate PII is redacted**

If you see only `{"candidate": {"id": "cand_..."}}` without name or email:

1. **Expected behavior:** This happens when the token has `applications:read` but not `candidates:read`
2. **Add scope:** To see full candidate details, add the `candidates:read` scope to your token
3. **Privacy by design:** This lets integrations track applications without exposing contact info

### Query and Filtering Issues

**404 when looking up specific records**

1. **Use prefix IDs:** Job postings use `job_abc123` format, not database IDs like `42`
2. **Case sensitivity:** Prefix IDs are case-sensitive
3. **Account scoping:** The resource must belong to the token's account

**Filter parameters not working**

1. **Valid status values:**
   - Job postings: `draft`, `published`, `closed` (case-sensitive)
   - Applications: `active`, `rejected` (case-sensitive)
2. **Parameter format:** Use `?status=published`, not `?status=Published` or `?filter[status]=published`

### Frequently Asked Questions

**How does pagination work?**

All list endpoints return 20 items per page (fixed). Use `?page=2` for the next page. The `pagination` object shows `total_pages` and `total_count`.

**Do tokens expire automatically?**

Only if you set `expires_at` when creating the token. Otherwise, tokens remain valid until:
- Manually revoked in Settings > API
- User is removed from the account
- Account is deleted

**Should I use one token for multiple integrations?**

No. Best practice is one token per integration with minimal scopes. This makes it easier to:
- Audit which integration is making requests (via `last_used_at`)
- Revoke access to a specific integration without affecting others
- Grant different permissions to different systems

**How do webhooks relate to the API?**

Webhooks notify you of events (new application, stage change). The API lets you query current state. Use webhooks to trigger your system, then use the API to fetch full details.

**Can I get more than 20 items per page?**

No. The page size is fixed at 20 items to ensure consistent performance. Use the `page` parameter to iterate through all results.

## Quick Checklist

- [ ] Create an API token at **Settings > API** — each token is bound to that account
- [ ] Assign only the scopes your integration needs (best practice: one token per integration with minimal scopes)
- [ ] Check `expires_at` in Settings > API to see when the token expires (if set)
- [ ] Include `Authorization: Bearer TOKEN` on every request
- [ ] Use prefix IDs (e.g., `job_abc123`) in URLs, not database IDs
- [ ] Handle `400` and `422` responses for validation errors
- [ ] Handle `401` responses — your token may be expired or revoked
- [ ] Handle `403` responses — your token may lack a required scope
- [ ] Use `page` parameter to paginate through large result sets (20 items per page)