Sync time and attendance to payroll with your own IDs

Send pay items from your time and attendance system with partner_external_id, so resends, changes and corrections never create duplicates.

Your time and attendance (T&A) system sends hours worked, overtime and leave to Remote Payroll as pay items. The same timesheet line often has to be sent more than once: the job retries after a timeout, a manager fixes the hours, or the line is deleted. This guide shows how to handle each of those cases with partner_external_id, so Remote always holds one pay item per T&A record.

📘

New to partner_external_id?

Your own IDs on Remote records explains the field itself: who can see your values, how long they can be, and how to find a record by one. This guide shows how to use it with pay items.

🧭

This is part of your payroll implementation

Which pay codes you use, how you date them and your payroll cutoffs are agreed with Remote during your payroll implementation. Wherever this guide says confirm with your Remote contact, check the detail with your payroll implementation or payroll support contact.

Who this is for

  • Global Payroll customers who track time in another system and send it to Remote through the API.
  • Engineers and integration partners building that connection for a customer.

Before you start

  • Global Payroll employments. Pay items can only be created for employees on Remote Global Payroll.
  • A company-level access token with the pay_item:write scope (or a broader one such as payroll or all:write). Integration partners: use a token the customer has authorised. Your own client-credentials token can list pay codes but can't create pay items.
  • The pay codes for each legal entity. List them with GET /v1/companies/{company_id}/legal-entities/{legal_entity_id}/pay-codes. Send amount as a whole number in the unit of the code's type, for example seconds for a duration code (7.5 hours → 27000). Always convert from the code's type: if you send 8 (meaning hours) to a duration code, it is stored as 8 seconds and the request still succeeds.
  • Your payroll cutoffs. Pay items need to arrive before the input cutoff of their payroll run. Read it from GET /v1/payroll-calendars/{cycle} (input_cutoff_date).
  • partner_external_id turned on for pay items. It is being rolled out. Until it is on for you, requests that send it fail with "partner_external_id is not available". Ask your Remote contact.
  • A Sandbox to test in: https://gateway.remote-sandbox.com.

What's in scope

This guide covers one-off pay items: a pay item for one day or one period, such as a shift's hours, an overtime entry or a leave period. That is what a T&A system sends.

🚧

Recurring pay items are out of scope

Recurring pay items, such as a fixed monthly allowance set up in the Remote app, don't work with this flow:

  • GET /v1/pay-items doesn't return them. It returns the monthly payments generated from them instead.
  • PATCH and DELETE refuse them with 422 "Recurring pay items cannot be updated or deleted through this API."
  • A monthly payment generated from a recurring pay item can be regenerated with a new id when the recurring item changes. Its old id then returns 404.

Manage recurring pay items in the Remote app, and use partner_external_id only for the one-off pay items your T&A system sends.

Pick your ID

Use the ID of the T&A record a pay item comes from: a timesheet line, a shift or a leave request. It must be:

  • Stable. The same record always has the same ID, every time you send it.
  • Unique per record. Two records never share an ID. An employee ID doesn't work: all of that employee's rows would share it.
  • Up to 64 characters, and never null or empty.

If more than one of your systems sends pay items, add a prefix per system, for example ta:TS-0042 and expenses:EX-17.

How Remote handles each row

You send every row through POST /v1/pay-items/bulk, up to 500 rows per request. Remote looks at each row's partner_external_id on its own:

The row sendsWhat happens
A value no live pay item holdsCreates a new pay item that holds it
A value a live pay item holdsUpdates that pay item's amount, effective_date, end_date and provider_data
No partner_external_idCreates a new pay item. Sending it again creates a second one.
null or an empty stringThe row fails with partner_external_id_blank

Each scenario below builds on this.


Scenario 1: Send a new timesheet

An employee worked 7.5 hours on 12 October. Your timesheet line is TS-0042.

curl -X POST "https://gateway.remote-sandbox.com/v1/pay-items/bulk" \
  -H "Authorization: Bearer $REMOTE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "pay_items": [
      {
        "employment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "code": "worked_hours",
        "effective_date": "2026-10-12",
        "amount": 27000,
        "partner_external_id": "TS-0042"
      }
    ]
  }'

No pay item holds TS-0042 yet, so Remote creates one:

{
  "data": {
    "successes": [
      {
        "row_number": 0,
        "pay_item": {
          "id": "65cb9aeb-54dd-409e-924f-a8ae1d9d1397",
          "employment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
          "code": "worked_hours",
          "effective_date": "2026-10-12",
          "end_date": null,
          "amount": 27000,
          "partner_external_id": "TS-0042",
          "provider_data": {},
          "replaced_ids": []
        }
      }
    ],
    "failures": []
  }
}
❗️

A 200 OK does not mean every row was saved

Always read failures[]. A row that breaks a business rule is listed there, and the other rows are still saved. A format problem in any row (a decimal amount, a value over 64 characters, more than 500 rows) returns 422 instead, and nothing in the request is saved.

You don't need to store Remote's id. Your partner_external_id is enough to find, change and reconcile the pay item later.

Scenario 2: Your job times out, and you send it again

Your job sent 300 rows, then the connection dropped. You don't know which rows landed.

Send the same request again, with the same IDs. Rows that landed the first time are updated with the same values. Rows that didn't land are created. Either way, each timesheet line ends up as one pay item.

👍

This is the main reason to use partner_external_id

Without it, a retry creates a second pay item for every row that already landed, and the employee is paid twice.

If you retry while the first request is still running, both requests may try to create the same pay item. Only one of them can. The other request's row fails with {"errors": {"partner_external_id": ["has already been taken"]}}. That means the row already landed: read it back with GET /v1/pay-items?partner_external_id=... and don't treat it as broken. To avoid this, wait for a request to finish or time out before you retry it.

Scenario 3: A manager changes the hours before payroll cutoff

The manager changes TS-0042 from 7.5 to 7 hours. Send the line again with the same ID and the new amount:

{
  "employment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "code": "worked_hours",
  "effective_date": "2026-10-12",
  "amount": 25200,
  "partner_external_id": "TS-0042"
}

Remote finds the pay item that holds TS-0042 and updates it. What you get back depends on what changed:

  • amount or effective_date changed: Remote replaces the pay item with a new version. The response has a new id, and the old one is listed in replaced_ids. TS-0042 moves to the new version, so you keep sending the same value.
  • Only provider_data or end_date changed: the pay item keeps its id.
  • Nothing changed: nothing happens, and the pay item keeps its id.

provider_data you send is merged into what's stored: keys you leave out keep their old values.

Old Remote IDs keep working: PATCH and DELETE on any id Remote returned act on the current version.

The employee and pay code can't change this way. If a row sends TS-0042 with another employee or another pay code, it fails with partner_external_id_held_by_other_pay_item. That usually means two of your records share an ID. To move hours to another employee or pay code, delete the pay item first (Scenario 4). That frees the ID, so you can then send the row again with the same ID.

Scenario 4: A timesheet line is deleted before payroll cutoff

Find the pay item by your ID, then delete it:

curl "https://gateway.remote-sandbox.com/v1/pay-items?partner_external_id=TS-0042" \
  -H "Authorization: Bearer $REMOTE_API_TOKEN"

curl -X DELETE "https://gateway.remote-sandbox.com/v1/pay-items/$PAY_ITEM_ID" \
  -H "Authorization: Bearer $REMOTE_API_TOKEN"

A successful delete returns {"data": {"status": "ok"}} and frees TS-0042. If you send TS-0042 again later, Remote creates a new pay item. PATCH or DELETE on the deleted id returns 404 "Pay item not found because it was deleted".

Scenario 5: Hours change after payroll has locked

The run for 14 October has locked, and the manager finds the employee worked 5 hours, not 8. If you resend TS-0050 with the new amount, the row fails with unprocessable_entity and one of these messages:

  • Associated payroll run is no longer in preparation
  • Associated payroll run has a cut off date which is in the past.

That's your signal to leave the original pay item alone and send a correction: a new pay item, with a new ID of its own.

A third message, "This payroll run's figures are being updated. Try again in a few minutes.", is different: the run hasn't locked, so retry the same row later instead of sending a correction.

{
  "employment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "code": "worked_hours",
  "effective_date": "2026-10-21",
  "amount": -10800,
  "partner_external_id": "TS-0050-correction-1",
  "provider_data": { "correction_effective_date": "2026-10-14" }
}
  • effective_date is the day you send the correction.
  • provider_data.correction_effective_date is the original day. Set it only on corrections: it's what marks a pay item as one.
  • amount is the difference.
  • partner_external_id is new, because TS-0050 still belongs to the locked pay item. Keep the correction's ID stable too, so a retry of the correction doesn't duplicate it.

Whether a negative amount is the right way to send a correction for your pay codes depends on your setup. Confirm with your Remote contact.

Scenario 6: Leave that spans several days

Send one pay item for the whole period: effective_date is the first day and end_date the last (inclusive). Use the leave request's ID.

{
  "employment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "code": "unpaid_leave",
  "effective_date": "2026-10-05",
  "end_date": "2026-10-30",
  "amount": 576000,
  "partner_external_id": "LEAVE-0007"
}

If the leave is shortened before cutoff, send LEAVE-0007 again with the new end_date and amount. Only set end_date on leave-of-absence pay codes.

Scenario 7: Reconcile before each cutoff

Read back what Remote holds for the period:

curl "https://gateway.remote-sandbox.com/v1/pay-items?legal_entity_id=$LEGAL_ENTITY_ID&effective_from=2026-10-01&effective_to=2026-10-31&page_size=100&page=1" \
  -H "Authorization: Bearer $REMOTE_API_TOKEN"

Match each pay item to your T&A record on partner_external_id, then compare employee, pay code, effective date and amount. Look for anything missing, anything extra and any difference.

  • Page through until current_page equals total_pages.
  • Pay items without your ID came from somewhere else: the Remote app or another system. Agree with your Remote contact which system owns which pay codes.

When a row fails

Each entry in failures[] has a row_number and an error. row_number is the row's position in the pay_items array you sent, starting at 0. The error comes in three shapes, so check which one you got before you compare it:

{
  "failures": [
    { "row_number": 0, "error": "pay_item_code_not_allowed" },
    { "row_number": 1, "error": { "code": "partner_external_id_blank", "message": "partner_external_id must not be null or blank when creating pay items" } },
    { "row_number": 2, "error": { "errors": { "partner_external_id": ["has already been taken"] } } }
  ]
}
errorWhat it meansWhat to do
{code: "partner_external_id_held_by_other_pay_item"}Your ID is on a pay item for another employee or another pay codeCheck your IDs are unique per record
{code: "partner_external_id_repeated_in_request"}The same ID appears twice in one request. The message names the first row that used it.Send each record once per request
{code: "partner_external_id_ambiguous"}More than one live pay item holds your IDContact Remote support
{code: "partner_external_id_blank"}The value was null, empty or only spacesLeave the field out, or send a real ID
{code: "partner_external_id_not_available"}The field isn't turned on for you yetAsk your Remote contact
{errors: {partner_external_id: ["has already been taken"]}}Another request created a pay item with this ID at the same timeIt already landed: read it back (Scenario 2)
{code: "unprocessable_entity"}, payroll run no longer in preparation or past cutoffThe pay item's run has lockedSend a correction (Scenario 5)
{code: "unprocessable_entity"}, figures being updatedThe run is being recalculatedRetry in a few minutes
{code: "unprocessable_entity"}, recurring pay itemYour ID is held by a recurring pay itemOut of scope: see What's in scope
{code: "unprocessable_entity"}, other messageThe resend broke a rule, for example a date after the employee's termination dateRead the message
"pay_item_code_not_allowed"The pay code isn't accepted on that employee's legal entityCheck the legal entity's pay codes
"employment_not_found" / "employment_not_global_payroll"The employee doesn't exist for your token, or isn't on Global PayrollCheck the employment ID

For AI agents

  • Send a partner_external_id on every row: the source record's own ID, stable, unique per record, at most 64 characters, never null or empty.
  • A bulk request whose rows all carry partner_external_id is safe to retry. Without it, read back first: a retry creates duplicates.
  • To change a record before cutoff, resend it with the same ID. To delete it, find it with GET /v1/pay-items?partner_external_id=... and DELETE it.
  • A failure saying the payroll run is no longer in preparation, or that its cutoff has passed, means: send a correction with a new partner_external_id and provider_data.correction_effective_date.
  • Never use this flow for recurring pay items.
  • Treat any non-empty failures[] as a failed send, and any 422 as "nothing saved".
  • Don't decide effective-date conventions, negative values or pay code substitutions yourself. Ask the user to confirm them with their Remote contact.

Next steps

  • Your own IDs on Remote records, for how partner_external_id works across Remote.
  • API reference: Bulk Create Pay Items, List Pay Items, Update a Pay Item, Delete a Pay Item, List Pay Codes.

Did this page help you?