# recruiting.postings.save

Create a job posting (often from a job role with jobRoleId), or update one with id and expectedRevision: title, department, location, workplace (remote, hybrid, onsite), employmentType, description, payText, vacancyExists, aiAssist (disclosed on the posting), visibility (public or private), questions (key, label, type text/textarea/yes_no/select/url, required, options) and stages (Applied first, Hired last). The creator becomes hiring manager unless an administrator chooses someone; only an administrator or the current hiring manager can change it. New postings start as drafts.

`POST /api/v1/accounting/recruiting.postings.save`

Permissions: `accounting:read`, `accounting:write` · Roles: admin, finance · Idempotency key required · Send `expectedRevision`

MCP tool: `accounting_recruiting_postings_save`

## Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string (ID) |  | The record's ID. |
| `expectedRevision` | integer |  | The record's current revision, from the last time you read it. If someone changed the record since, the request is refused with a conflict so you can reload and check before trying again. at most 9007199254740991; greater than 0. |
| `posting` | object | Yes | No other fields. |
| `posting.jobRoleId` | string (ID) or null | Yes |  |
| `posting.title` | string | Yes | A short title. 1–200 characters. |
| `posting.department` | string | Yes | at most 120 characters. |
| `posting.location` | string | Yes | at most 200 characters. |
| `posting.workplace` | enum | Yes | One of: `remote`, `hybrid`, `onsite`. |
| `posting.employmentType` | enum | Yes | One of: `contractor`, `employee`, `either`. |
| `posting.description` | string | Yes | A short description. at most 40000 characters. |
| `posting.payText` | string | Yes | at most 200 characters. |
| `posting.vacancyExists` | boolean | Yes |  |
| `posting.aiAssist` | boolean | Yes |  |
| `posting.visibility` | enum | Yes | One of: `public`, `private`. |
| `posting.questions` | array of objects | Yes | at most 20 items. |
| `posting.questions[].key` | string | Yes | Matches ^[a-z][a-z0-9_]{0,39}$. |
| `posting.questions[].label` | string | Yes | 1–300 characters. |
| `posting.questions[].type` | enum | Yes | One of: `text`, `textarea`, `yes_no`, `select`, `url`. |
| `posting.questions[].required` | boolean | Yes |  |
| `posting.questions[].options` | array of strings |  | at most 20 items; each 1–200 characters. |
| `posting.stages` | array of objects | Yes | 2–12 items. |
| `posting.stages[].id` | string | Yes | The record's ID. Matches ^[a-z][a-z0-9_]{0,39}$. |
| `posting.stages[].label` | string | Yes | 1–60 characters. |
| `posting.hiringManagerUserId` | string or null | Yes |  |
| `idempotencyKey` | string | Yes | Any unique text you generate once per intended change, so a retried request only happens once. Send it as the Idempotency-Key header instead if you prefer; if you send both they must match. 8–200 characters. |

## Example request

```bash
curl https://app.getoatmilk.com/api/v1/accounting/recruiting.postings.save \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "posting": {
    "jobRoleId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "title": "Synthetic services agreement",
    "department": "example",
    "location": "example",
    "workplace": "remote",
    "employmentType": "contractor",
    "description": "Synthetic example from the docs",
    "payText": "example",
    "vacancyExists": true,
    "aiAssist": true,
    "visibility": "public",
    "questions": [
      {
        "key": "key",
        "label": "example",
        "type": "text",
        "required": true
      }
    ],
    "stages": [
      {
        "id": "id",
        "label": "example"
      },
      {
        "id": "id",
        "label": "example"
      }
    ],
    "hiringManagerUserId": "example"
  }
}'
```

Reference page: https://app.getoatmilk.com/docs/api/recruiting.postings.save
