worker.contract.updated Event
Emitted when a worker's contract changes in any of these ways:
status(e.g.EXECUTED→OFFBOARD_IN_PROGRESS→OFFBOARDED)jobTitlemanagerId
Other field changes on the contract do not emit this event.
| Property | Value |
|---|---|
| Type | worker.contract.updated |
| Data version | 2 |
| Entity | entity.type = "workerContract", entity.id = data.id |
| Fan-out | One delivery per matching subscription |
| Mutual exclusion | Suppressed for the EXECUTED transition that triggers worker.onboarded. You receive that. |
Version history
dataVersion | Change |
|---|---|
2 | The 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. |
1 | Initial release. |
Contract status lifecycle
Status at creation
A new contract starts in a different status depending on its engagement
type:
engagementType | Status on creation |
|---|---|
EOR | PENDING_RELEASE |
PAYROLL | DRAFT |
CONTRACTOR | DRAFT |
CONTRACTOR_PLUS | DRAFT |
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
EXECUTEDThe route to EXECUTED differs by engagement type, and not every step
is delivered as an event:
| Transition | What causes it | Delivered? |
|---|---|---|
DRAFT → AWAITING_SIGNATURES | The client invites the worker to the contract. | Yes |
AWAITING_SIGNATURES → EXECUTED | Both parties sign. | Yes |
PENDING_RELEASE → OFFER_DRAFT | An EOR offer is prepared or an offer letter uploaded. | No |
OFFER_DRAFT → OFFER_SENT | The offer letter is sent to the worker. | No |
OFFER_SENT → OFFER_ACCEPTED | The worker accepts the offer. | Yes |
OFFER_ACCEPTED → PENDING_RELEASE | EOR onboarding is requested. | No |
PENDING_RELEASE → EXECUTED | The 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
EXECUTED onwardThe 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:
status | Meaning | How it is reached |
|---|---|---|
EXECUTED | The contract is active. | Signed and released. Also the value reported while a contract is on hold. |
OFFBOARD_IN_PROGRESS | Offboarding 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. |
OFFBOARDED | Terminal. 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
data schemaA single contract snapshot mirroring the public API's WorkerContract
schema, plus two routing fields (workerId, and a links.self pointing
at the parent worker).
| Field | Type | Description |
|---|---|---|
id | string | Public contract ID (lglsoctc_…). Same value as entity.id. |
clientId | string | Public client ID this contract is on. |
workerId | string | Public worker ID this contract belongs to (lglsowpr_…). |
name | string | Contract display name at the moment of emit. |
status | string | Contract status at the moment of emit. See Contract status lifecycle for the values you can receive and which transitions are silent. |
billingFrequency | string | E.g. MONTHLY, BIWEEKLY. |
isoCountryCode | string | Country of work. |
jobTitle | string | Job title at the moment of emit. |
startDate | ISO-8601 date | Contract start. |
contractEndDate | string | null | ISO-8601 date or null for open-ended. |
createdAt | ISO-8601 UTC | When the contract record was first created in Listo. Immutable. |
engagementType | string | E.g. EOR, CONTRACTOR. |
managerId | string | null | Public user ID of the manager, or null. |
links.self | string | Path to the parent worker (no per-contract public endpoint exists yet). |
🛈 No per-contract public endpoint.
links.selfpoints at the
parent worker — fetch the full worker, find the matching contract by
idin the worker'scontracts[]. 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.updatedfor those. - No "previous" values. This is a forward snapshot, not an audit-log
entry.
Mutual exclusion with worker.onboarded
worker.onboardedThe 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.updatedevents for
the same contract may arrive out ofoccurredAtorder. Order by
occurredAtif your downstream needs sequence accuracy. - A
worker.contract.updatedmay arrive before the
worker.onboardedfor 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.
Updated 8 days ago
