worker.contract.updated Event

Emitted when a worker's contract changes in any of these ways:

  • status (e.g. EXECUTEDOFFBOARD_IN_PROGRESSOFFBOARDED)
  • jobTitle
  • managerId

Other field changes on the contract do not emit this event.

PropertyValue
Typeworker.contract.updated
Data version2
Entityentity.type = "workerContract", entity.id = data.id
Fan-outOne delivery per matching subscription
Mutual exclusionSuppressed for the EXECUTED transition that triggers worker.onboarded. You receive that.

Version history

dataVersionChange
2The documented status values were corrected to the ones the system actually emits — PENDING_SIGNATURE and TERMINATED never existed. Contracts reaching OFFBOARDED through the scheduled end-date job now emit this event; that transition was previously silent. The data shape is unchanged from 1 — no parsing change is required.
1Initial release.

Contract status lifecycle

Status at creation

A new contract starts in a different status depending on its engagement
type:

engagementTypeStatus on creation
EORPENDING_RELEASE
PAYROLLDRAFT
CONTRACTORDRAFT
CONTRACTOR_PLUSDRAFT

Creating a contract does not emit worker.contract.updated. The
first delivery you receive for a contract is its first change — a
status transition, or an edit to jobTitle / managerId.

Before EXECUTED

The route to EXECUTED differs by engagement type, and not every step
is delivered as an event
:

TransitionWhat causes itDelivered?
DRAFTAWAITING_SIGNATURESThe client invites the worker to the contract.Yes
AWAITING_SIGNATURESEXECUTEDBoth parties sign.Yes
PENDING_RELEASEOFFER_DRAFTAn EOR offer is prepared or an offer letter uploaded.No
OFFER_DRAFTOFFER_SENTThe offer letter is sent to the worker.No
OFFER_SENTOFFER_ACCEPTEDThe worker accepts the offer.Yes
OFFER_ACCEPTEDPENDING_RELEASEEOR onboarding is requested.No
PENDING_RELEASEEXECUTEDThe contract is released and executed.Yes

Because three of those transitions are silent, the sequence you observe
has gaps. A typical EOR contract surfaces as OFFER_ACCEPTED, then
EXECUTED — the offer-preparation steps in between never reach you. A
contractor or payroll contract surfaces as AWAITING_SIGNATURES, then
EXECUTED.

Two statuses in the underlying enum are never delivered at all:
AGREEMENT_DRAFT is not used by any flow, and ON_HOLD is derived at
read time rather than stored — a contract on hold continues to report
EXECUTED.

From EXECUTED onward

The transition into EXECUTED arrives as
worker.onboarded for the first
contract under a worker — see Mutual exclusion
below. From that point on, every status this event reports is one of:

statusMeaningHow it is reached
EXECUTEDThe contract is active.Signed and released. Also the value reported while a contract is on hold.
OFFBOARD_IN_PROGRESSOffboarding has been requested. contractEndDate carries the last working day; the worker stays active until then.A client submits an offboarding request for an active contract.
OFFBOARDEDTerminal. The contract has ended.Set directly when a client offboards a worker outright, or automatically by a daily job once contractEndDate has passed.

So two sequences are normal:

EXECUTED → OFFBOARD_IN_PROGRESS → OFFBOARDED   (offboarding scheduled ahead of the last working day)
EXECUTED → OFFBOARDED                          (offboarded outright)

OFFBOARDED is terminal — no further worker.contract.updated follows
for that contract.

All engagement types (EOR, PAYROLL, CONTRACTOR, CONTRACTOR_PLUS)
use the same two statuses — there is no engagement-specific status.


Sample delivery

POST /your/webhook/path HTTP/1.1
Host: hooks.your-domain.com
Content-Type: application/json
User-Agent: ListoGlobal-Gateway/1.0
webhook-id: lglsoevt_uZN4sQTuCK8NbXgE3
webhook-timestamp: 1746358200
webhook-signature: v1,Y9pXkR2nA5bMqWHj7zEFvLBcYIDsoCLeGJxQXM3yTbY=

{
  "id": "lglsoevt_uZN4sQTuCK8NbXgE3",
  "type": "worker.contract.updated",
  "specVersion": 1,
  "dataVersion": 2,
  "occurredAt": "2026-05-04T12:10:00.118Z",
  "entity": { "type": "workerContract", "id": "lglsoctc_uXYZ1aBcD2EfG3hJ4" },
  "data": {
    "id": "lglsoctc_uXYZ1aBcD2EfG3hJ4",
    "clientId": "lglsocli_uZIIHfKqYBwyaRGGs",
    "workerId": "lglsowpr_uZIIQiu8Q5zhUXgbR",
    "name": "Alex Kim — Staff Engineer",
    "status": "EXECUTED",
    "billingFrequency": "MONTHLY",
    "isoCountryCode": "US",
    "jobTitle": "Staff Engineer",
    "startDate": "2026-05-02",
    "contractEndDate": null,
    "createdAt": "2026-04-28T17:09:11.000Z",
    "engagementType": "EOR",
    "managerId": "lglsousr_uXYZxLtq9ABvCdEf2",
    "links": {
      "self": "/v2/instance-integrations/inst_abc123xyz/clients/lglsocli_uZIIHfKqYBwyaRGGs/workers/lglsowpr_uZIIQiu8Q5zhUXgbR"
    }
  }
}

data schema

A single contract snapshot mirroring the public API's WorkerContract
schema, plus two routing fields (workerId, and a links.self pointing
at the parent worker).

FieldTypeDescription
idstringPublic contract ID (lglsoctc_…). Same value as entity.id.
clientIdstringPublic client ID this contract is on.
workerIdstringPublic worker ID this contract belongs to (lglsowpr_…).
namestringContract display name at the moment of emit.
statusstringContract status at the moment of emit. See Contract status lifecycle for the values you can receive and which transitions are silent.
billingFrequencystringE.g. MONTHLY, BIWEEKLY.
isoCountryCodestringCountry of work.
jobTitlestringJob title at the moment of emit.
startDateISO-8601 dateContract start.
contractEndDatestring | nullISO-8601 date or null for open-ended.
createdAtISO-8601 UTCWhen the contract record was first created in Listo. Immutable.
engagementTypestringE.g. EOR, CONTRACTOR.
managerIdstring | nullPublic user ID of the manager, or null.
links.selfstringPath to the parent worker (no per-contract public endpoint exists yet).

🛈 No per-contract public endpoint. links.self points at the
parent worker — fetch the full worker, find the matching contract by
id in the worker's contracts[]. A dedicated contract endpoint may
be added later; the link will swap to that without bumping
dataVersion.


What's not included

  • No changedFields. Diff against your stored copy if you care which
    field moved.
  • No worker profile fields (name, email, etc.) — listen for
    worker.updated for those.
  • No "previous" values. This is a forward snapshot, not an audit-log
    entry.

Mutual exclusion with worker.onboarded

The first contract for a (worker, client) pair to reach EXECUTED
triggers worker.onboarded. For that
specific status transition Listo suppresses the
worker.contract.updated that would otherwise fire — there is exactly
one event for that moment, and it is worker.onboarded.

For every other change to a contract — later status changes, job-title
edits, manager re-assignments, EXECUTED transitions on subsequent
contracts under the same worker-client pair — worker.contract.updated
fires as documented above.

The mutual exclusion is enforced at the producer; you do not need to
deduplicate between the two event types yourself.


Ordering and duplication

  • Duplicates are normal. Dedupe on webhook-id.
  • Order is not guaranteed. Two worker.contract.updated events for
    the same contract may arrive out of occurredAt order. Order by
    occurredAt if your downstream needs sequence accuracy.
  • A worker.contract.updated may arrive before the
    worker.onboarded for the same worker if the events were produced
    close together — handle gracefully (upsert by (clientId, workerId, contractId), don't insist on prior existence).

Common patterns

"Track contract status changes"

1. Verify signature, dedupe on webhook-id.
2. UPSERT INTO worker_contracts (id, ...)
   ON CONFLICT (id) DO UPDATE
   SET status = EXCLUDED.status,
       job_title = EXCLUDED.job_title,
       manager_id = EXCLUDED.manager_id,
       last_event_at = EXCLUDED.last_event_at
   WHERE worker_contracts.last_event_at < EXCLUDED.last_event_at;
3. If new status is OFFBOARD_IN_PROGRESS, schedule your downstream
   offboarding flow for `contractEndDate`. If it is OFFBOARDED, run that
   flow now.
4. Respond 200.

The last_event_at guard prevents a stale retry from overwriting newer
state.

"Notify a Slack channel on contract status change"

A common shape: filter data.status against a list of statuses you care
about and post to Slack. Make sure to dedupe on webhook-id first — at
least-once delivery means duplicate Slack messages otherwise.

"Use it as a manager-change feed"

managerId lives on the contract, not the worker — manager re-assignments
flow through this event, not worker.updated. Hook this if you need
real-time updates of "who reports to whom" for org-chart automation.


Did this page help you?