Creating and submitting an EOR employment

This guide will show you how to create a draft employment, update the job details, and submit the employment to kick off the employment onboarding and contract generation.

Note for Reseller partners using the API - you will need to use the X-Oyster-Customer-Id header for all of the calls in this guide. For more information, see the Reseller guide to using the Oyster API.


1. Create Employment

First you need to create an employment record, which represents the start of the hiring process for an engagement. In your request, you will need to include the employment’s ISO-3166 Alpha country code, full legal name, email, and nationality.

Example Request

curl --request POST \
     --url https://api.oysterhr.com/v0.1/hiring/employments/ \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json' \
     --data '
{
  "personalDetails": {
    "fullLegalName": "Test Hire",
    "email": "[email protected]",
    "nationality": "US"
  },
  "countryCode": "US",
  "subdivisionCode": "CA"
}
'

Response

If successful, the API will return a 200 Created response with the newly created employment details, including an id that you will need for future updates and submission. The employment will then be in a draft phase and ready to continue filling the contractual details.

Example Response

{
  "data": {
    "engagementId": "urY7zKOm",
    "countryCode": "US",
    "subdivisionCode": "CA",
    "personalDetails": {
      "fullLegalName": "Test Hire",
      "email": "[email protected]",
      "nationality": "US",
      "workPermit": null
    },
    "manager": {
      "managerRole": null,
      "managerName": null,
      "managerEmailAddress": null,
      "managerPhoneNumber": null,
      "managerIsExpenseApprover": false
    },
    "role": {
      "newHireStatus": "new_hire",
      "seniorityLevel": null,
      "roleTypeId": null,
      "role": null,
      "jobDescription": null,
      "startDate": null,
      "endDate": null,
      "employmentType": null,
      "probationPeriod": null,
      "terminationNotice": null,
      "resignationNotice": null
    },
    "compensation": {
      "commissionPlanOffered": null,
      "commissionPlan": null,
      "salary": {
        "decimal": "",
        "currencyCode": "USD"
      },
      "paidHolidayDays": null,
      "bonuses": [],
      "allowances": [],
      "reimbursementBudgets": [],
      "remoteWorkCosts": null,
      "privateNotes": null,
      "sickPay": null
    },
    "documents": [],
    "signer": null
  }
}

2. Update Employment

Once you’ve created the employment, you will need to update the employment with details related to the job position. In the request body, only include the fields you want to update (e.g., job_title, annual_salary, etc.).

Example Request

curl --request PATCH \
     --url https://api.oysterhr.com/v0.1/hiring/employments/urY7zKOm \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json' \
     --data '
{
  "role": {
    "role": "Senior Software Engineer"
  },
  "compensation": {
    "salary": {
      "decimal": "95000",
      "currencyCode": "USD"
    },
  }
}
'

To successfully submit an Employment in the next step, certain objects are required based on the Employment's designated country and employmentType. The schema includes 5 principle objects, some of which are required for employment submission:

NameRequired?
managerNo
roleYes
personalDetailsYes
compensationYes
signerYes

Within the required objects, not all items are required. The following tables show the required items for each required object. For more information about required fields for the different objects in the following tables, see the documentation.

Within the role object, the following are required:

NameTypeNotes
seniorityLevelfield
roleTypeId / rolefieldThe job title can be looked up using the Role Types endpoint or can be provided as free text (role)
jobDescriptionfieldRequired for all non-US employments. See below.
dailyResponsibilityfieldRequired only for all US employments. See below.
primaryDutyfieldRequired only for all US employments. See below.
decisionMakingAuthorityfieldRequired only for all US employments. See below.
minimumQualificationfieldRequired only for all US employments. See below.
workScheduleobjectOnly required for US and PH employments. See below.
startDatefield
endDatefieldOnly for fixed-term employments
employmentTypefieldThis must be either FULL_TIME or PART_TIME.
fullTimeHoursPerWeekobjectOnly required for FULL_TIME employments.
partTimeDaysPerWeekobjectOnly required for PART_TIME employments.
partTimeHoursPerWeekobjectOnly required for PART_TIME employments.
probationPeriodobject
terminationNoticeobjectThis field is required in: Australia, UK, Ireland, India.
resignationNoticeobjectThis field is required in: Australia, UK, Ireland, India.

Within the compensation object, the following are required:

NameTypeNotes
salaryobject
paidHolidayDaysfield
remoteWorkCostsobjectThis covers equipment policy ("bring your own" or "equipment provided by the employer"), equipment reimbursement (budget or one-time payment), and allowance policy (statutory minimum present in some countries). Use the Update employment documentation to see the object structure and the ruleset to get valid values for its properties.
sickPayobjectThis field is supported and required only in: UK, Ireland, Netherlands. Use the Update employment endpoint documentation to see the object structure and the ruleset to get valid values for option and customDuration. For other countries this object is not supported, and supplying it would result in 422 Unprocessable Entity..

Within the signer object, the following are required:

NameTypeNotes
fullNamestring
jobTitlestring
emailstringIf company signatory exists with matching email, the point of contact record is updated with remaining fields in signer payload. If no signatory with matching email is found, a new one is created.
phoneobject

Response

A successful update returns the modified employment details. The status will still be draft until the employment is submitted in the following step.

Example Response

{
  "data": {
    "engagementId": "urY7zKOm",
    "countryCode": "US",
    "subdivisionCode": "CA",
    "personalDetails": {
      "fullLegalName": "Test Hire",
      "email": "[email protected]",
      "nationality": "US",
      "workPermit": null
    },
    "manager": {
      "managerRole": null,
      "managerName": null,
      "managerEmailAddress": null,
      "managerPhoneNumber": null,
      "managerIsExpenseApprover": false
    },
    "role": {
      "newHireStatus": "new_hire",
      "seniorityLevel": "SENIOR",
      "roleTypeId": null,
      "role": "Senior Software Engineer",
      "jobDescription": null,
      "startDate": null,
      "endDate": null,
      "employmentType": null,
      "probationPeriod": null,
      "terminationNotice": null,
      "resignationNotice": null,
      "fullTimeHoursPerWeek": null
    },
    "compensation": {
      "commissionPlanOffered": null,
      "commissionPlan": null,
      "salary": {
        "decimal": "95000",
        "currencyCode": "USD"
      },
      "paidHolidayDays": null,
      "bonuses": [],
      "allowances": [],
      "reimbursementBudgets": [],
      "remoteWorkCosts": null,
      "privateNotes": null,
      "sickPay": null
    },
    "documents": [],
    "signer": null
  }
}

Job description fields

The shape of the job description depends on the employment's country. For most countries you describe the role in a single free-text jobDescription field.

jobDescription — generic single free-text field, used for every non-US country.

  • Guidance: “Enter a description of the role here. Failure to provide complete and accurate job description information may cause the Team Member’s onboarding to be delayed.”
  • Length: Max 5000 chars.

For United States hires, Oyster instead collects the same information as three separate, more specific fields — dailyResponsibility, primaryDuty and decisionMakingAuthority — alongside a minimumQualification field. A US employment cannot be submitted until its subsection fields are filled in.

dailyResponsibility :

  • Guidance: “List the specific tasks and duties that the Team Member will be responsible for on a day-to-day basis (minimum of 100 characters)”
  • Length: Min 100 / max 1000 chars.

primaryDuty — the single most important duty, selected from the daily responsibilities list.

  • Guidance: “Which of the daily responsibilities listed is the Team Member’s main job duty? (minimum of 100 characters)”
  • By primary duty, we mean the key job duty that will occupy the majority of the Team Member’s time, as opposed to more ancillary and non-essential tasks. (e.g., a Sales Director’s primary duty is managing the department, not personally selling.)
  • Length: Min 100 / max 1000 chars.

decisionMakingAuthority :

  • Guidance: “List the specific type of decisions the Team Member will have the authority to make independently, if any.”
  • Length: Min 100 / max 3000 chars.

minimumQualification :

  • Guidance: “Please list the minimum qualifications required for the role”
  • Length: Max 3000 chars, no minimum

The two styles are mutually exclusive:

  • For a US employment you send the subsection fields and omit jobDescription, and
  • for every other country you send jobDescription and omit the subsection fields.

All five fields are returned inside the role object of the employment response, so you can always tell which style a given employment uses.

Work schedule

Some countries require Oyster to record not just how many hours a team member works, but exactly which days they work and at what times. Where that applies, the role object carries a workSchedule array with one entry per working day.

Whether a country uses work schedules is part of Oyster's country configuration rather than something you declare in the request — it applies to United States and Philippines hires today. You can always tell which employments use it from the response or in the rulesets:

  • workSchedule is returned inside the role object only for employments whose country supports work schedules.
  • The key is absent entirely for every other country.

workSchedule is only accepted for employments whose country supports work schedules. If you send a non-empty workSchedule array for any other country, the request is rejected and nothing is saved, so only include the field for countries that use it. See the Errors table below.

Unlike the rest of the employment payload, workSchedule is replaced as a whole rather than merged.

When you send the array, Oyster creates or updates an entry for every day you include and deletes the entry for every day you leave out, so always send the complete week, not just the days you changed.

To remove the schedule altogether, send an empty array ("workSchedule": []). Oyster deletes every saved entry, and the role object then returns an empty workSchedule. An empty array is accepted for every country, including countries that do not support work schedules. To leave the schedule as it is, omit the workSchedule key from the request.

Example Request

curl --request PATCH \
  --url https://api.oysterhr.com/v0.1/hiring/employments/urY7zKOm \
  --header 'accept: application/json' \
  --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
  --header 'content-type: application/json' \
  --data '
{
  "role": {
    "workSchedule": [
      { "day": "MONDAY",    "fromTime": "09:00", "toTime": "17:00" },
      { "day": "TUESDAY",   "fromTime": "09:00", "toTime": "17:00" },
      { "day": "WEDNESDAY", "fromTime": "09:00", "toTime": "17:00" },
      { "day": "THURSDAY",  "fromTime": "09:00", "toTime": "17:00" },
      { "day": "FRIDAY",    "fromTime": "09:00", "toTime": "17:00" }
    ]
  }
}
'

Each entry in the array contains the following properties, all of them required:

NameTypeNotes
daystringOne of MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY. Send each day at most once.
fromTimestringStart of the working day in 24-hour HH:MM format, for example 09:00.
toTimestringEnd of the working day in 24-hour HH:MM format. A value earlier than fromTime describes a shift that runs past midnight: for example, 22:00 to 06:00 is a ten-hour overnight shift.

The schedule is returned in the same shape by the employment endpoints, so you can read it back, change the entries you need, and send the whole array again.

How the schedule is validated

The times you send are stored as given, and are checked against the country's working-hour rules when you submit the employment rather than when you update it. An intermediate state that does not yet add up is therefore accepted while the employment is still in draft.

At submission, the schedule is checked against four things:

  • Number of working days. The country defines a minimum and a maximum number of days per week. For part-time employments the number of entries must equal partTimeDaysPerWeek exactly.
  • Hours per day. Each entry's duration must not exceed the country's daily maximum. For part-time employments each day must match partTimeHoursPerDay exactly.
  • Hours per week. The sum of all entries must match fullTimeHoursPerWeek or partTimeHoursPerWeek, and must fall inside the range the country allows.
  • Night shifts. Where a country defines a night-shift window, a schedule that overlaps it is only accepted for team members whose CBA level permits night work.

These submission checks are being rolled out to selected customers. Where they are not yet enabled, a work schedule is stored exactly as sent and the employment can be submitted without it adding up to the country's working-hour rules. Enforcement of these checks will be communicated in advance.

If your account has access to the hiring rulesets endpoint, you can retrieve the day-count limits for a country ahead of time: they are returned as minItems and maxItems on role.workSchedule.

Optional steps

Depending on the desired contract conditions, the following optional endpoints can be used:


3. Complete the Dynamic Hiring Form

Some countries require extra country-specific information before an employment can be submitted — for example, the Collective Bargaining Agreement (CBA) level in Italy. Oyster serves these questions as a dynamic hiring form: a country-driven set of fields that Oyster can extend without adding new endpoints, so you do not need to change your integration when a country's requirements change.

Start by fetching the dynamic hiring form for the employment.

Example Request

curl --request GET \
     --url https://api.oysterhr.com/v0.1/hiring/employments/urY7zKOm/dynamic/form \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE'

Response

If a dynamic form is available for the employment's country, the API will return a 200 response containing the form's sections and its state. Each section has a title and an array of fields, and each field describes itself well enough to be rendered without any country-specific logic on your side.

The state reflects how far the form has progressed:

  • UNSTARTED — the form exists for this country but no answers have been saved yet.
  • DRAFT — some answers have been saved, but required fields are still outstanding.
  • COMPLETED — every required field that applies has been answered, and the employment can be submitted.

When the employment's country has no dynamic hiring form, the response contains an empty sections array and no state.

Example Response

{
  "data": {
    "sections": [
      {
        "title": "Professional Level",
        "fields": [
          {
            "id": "recently_categorised_in_cba_level",
            "type": "SELECT",
            "label": "Has this Team Member been recently categorised in a CBA Level in Italy?",
            "category": null,
            "required": true,
            "enabled": true,
            "value": null,
            "mode": "RADIO",
            "select": "ONE",
            "options": [
              { "value": "yes", "label": "Yes" },
              { "value": "no", "label": "No" },
              { "value": "not_sure", "label": "Not sure" }
            ]
          },
          {
            "id": "current_level_assigned",
            "type": "TEXT",
            "label": "What is the current or most recent level assigned to this Team Member in Italy?",
            "category": null,
            "required": true,
            "enabled": {
              "allOf": [
                { "fieldId": "recently_categorised_in_cba_level", "value": "yes" }
              ]
            },
            "value": null
          },
          {
            "id": "cba_assigned",
            "type": "TEXT",
            "label": "In which CBA?",
            "category": null,
            "required": true,
            "enabled": {
              "allOf": [
                { "fieldId": "recently_categorised_in_cba_level", "value": "yes" }
              ]
            },
            "value": null
          }
        ]
      }
    ],
    "state": "UNSTARTED"
  }
}

The example above is truncated — the Italy form returns sixteen fields in the Professional Level section, most of them gated by an enabled condition so that only a handful apply to any one hire.

When the country has no dynamic hiring form, the response is:

{
  "data": {
    "sections": []
  }
}

Each field object can contain the following properties:

NameTypeNotes
idstringThe field identifier to send back when updating answers.
typestringOne of TEXT, DATE, SELECT, COUNTRY, SUBDIVISION, GROUP.
labelstringThe question to show to the user.
requiredbooleanWhether an answer is required for the form to reach COMPLETED. Only required fields that are currently enabled are counted.
enabledboolean or objecttrue or false for a static field, or a condition tree referencing other fields by fieldId. See Conditional enabled fields below.
valuestring, array or nullThe currently saved answer, or null if unanswered. An array for SELECT fields with select: MULTI.
categorystring or nullOptional grouping label for the field.
assistiveText / assistiveTextPlainstringPresent only when the field has help text. assistiveTextPlain is the same content stripped of formatting.
validationMessagestringPresent when the field has a validation message to show on invalid input. Always present for DATE fields.
mode / select / optionsstring / string / arraySELECT fields only. mode is RADIO, DROPDOWN or CHECKBOX; select is ONE or MULTI; options is the list of value and label pairs to choose from.
patternstringTEXT and DATE fields only. A regular expression the value must match. DATE values must be YYYY-MM-DD.
title / fieldsstring / arrayGROUP fields only. A nested set of fields presented together, in place of label and value.

Updating the form

Send answers to the update the dynamic hiring form endpoint as a fields array of id and value pairs. As with the employment itself, only include the fields you want to update — previously saved answers you omit are left as they are.

curl --request PATCH \
     --url https://api.oysterhr.com/v0.1/hiring/employments/urY7zKOm/dynamic/form \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json' \
     --data '
{
  "fields": [
    {
      "id": "recently_categorised_in_cba_level",
      "value": "no"
    }
  ]
}
'

A successful update returns 200 with the full form in the same shape as the fetch response, so you can use it directly to render the next question. There is no separate endpoint to complete the form: each update re-checks the answers, and as soon as every required field that applies has a value, the state returned by the API is COMPLETED. Until then it is DRAFT, and you can keep sending partial updates.

Conditional enabled fields

Dynamic hiring form fields can carry an enabled condition that controls whether the field is applicable given the current state of the form. There are two types:

  • Static: the field is always enabled (true) or always disabled (false), regardless of other answers.
  • Conditional rule: the field is enabled or disabled depending on the value of one or more other fields in the form.

When a conditional rule evaluates to false, the field is considered disabled — it is not applicable for the current combination of answers, and any value it holds should be treated as irrelevant. On update, values for disabled fields are rejected and fields that become disabled are cleared automatically; on submit, any remaining values on disabled fields are cleared before the form transitions to completed.

Errors

CodeMessageCause
400Field is not part of the dynamic step.A field id in the request does not exist on this employment's dynamic hiring form.
400Field is duplicated in the request.The same field appears more than once in the fields array.
422The request contains a value for a conditionally disabled field.A field in the request is currently disabled by an enabled condition.
422Dynamic form cannot be updated after employment is submitted.The employment is no longer in draft. Complete the dynamic hiring form before submitting.

Error responses identify the field they came from in a field property, using the same field id returned by the fetch endpoint.


4. Submit Employment

Once all the necessary information is submitted, you can submit the employment for processing. This marks the employment as In Progress, at which point the employment onboarding can take place before the contracts are sent out for signature.

Example Request

curl --request POST \
     --url https://api.oysterhr.com/v0.1/hiring/employments/urY7zKOm/submit \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE'

Response

If successful, you will receive a 200 response with the employment information.

Example Response

{
  "data": {
    "engagementId": "urY7zKOm",
    "countryCode": "US",
    "subdivisionCode": "CA",
    "personalDetails": {
      "fullLegalName": "Test Hire",
      "email": "[email protected]",
      "nationality": "US",
      "workPermit": null
    },
    "manager": {
      "managerRole": null,
      "managerName": null,
      "managerEmailAddress": null,
      "managerPhoneNumber": null,
      "managerIsExpenseApprover": false
    },
    "role": {
      "newHireStatus": "new_hire",
      "seniorityLevel": "SENIOR",
      "roleTypeId": 05,
      "role": "Senior Software Engineer",
      "jobDescription": "job description",
      "startDate": "2024-10-14",
      "endDate": null,
      "employmentType": "FULL_TIME",
      "probationPeriod": {
        "value": 12,
        "unit": "MONTHS"
      },
      "terminationNotice": null,
      "resignationNotice": null,
      "fullTimeHoursPerWeek": 40
    },
    "compensation": {
      "commissionPlanOffered": null,
      "commissionPlan": null,
      "salary": {
        "decimal": "95000",
        "currencyCode": "USD"
      },
      "paidHolidayDays": 14,
      "bonuses": [],
      "allowances": [],
      "reimbursementBudgets": [],
      "remoteWorkCosts": {
        "allowancePolicy": {
          "allowancePolicy": "MONTHLY_ALLOWANCE",
          "amount": null,
          "statutoryAmount": null
        },
        "equipmentBudget": null,
        "equipmentListOwnedByTeamMember": null,
        "equipmentListProvidedByCustomer": {
          "equipmentList": [
            "KEYBOARD",
            "MOUSE",
            "PRINTER"
          ],
          "equipmentOther": null
        },
        "equipmentOneTimePayment": null
      },
      "privateNotes": null,
      "sickPay": null
    },
    "documents": [],
    "signer": {
      "fullName": "Graham Vandervort",
      "email": "[email protected]",
      "jobTitle": "Engineering Manager",
      "phone": {
        "countryCode": "US",
        "number": "555 654 5568"
      }
    }
  }
}

Notes:

  • Unless otherwise configured using the Benefits API or UI, hires submitted via API will have statutory Benefits applied.
  • For countries with a dynamic hiring form, submission requires the form to be in the COMPLETED state. If it is not, the request fails with Dynamic steps must be completed before submitting the employment.
  • The dynamic hiring form can only be updated while the employment is in draft. Any changes after submission must be made by contacting Oyster support.

Conclusion

Once you have successfully submitted an employment and it is in the In Progress state, the employment onboarding can begin. That can either be achieved via the platform by sending the team member an invitation or via API by following the Completing EOR employment onboarding via API guide.


Did this page help you?