Engagements

Define reusable Engagement templates, assign Payees to them with PayeeEngagements, and move an engagement through its lifecycle in the V3 API.

An engagement describes a piece of work and the terms a Payee works under. This page shows you how to create an Engagement template, assign a Payee to it, and activate, suspend, or end that assignment.

Three resources, three jobs

ResourceIn the Wingspan appWhat it is
EngagementEngagementA reusable template you define once, such as "Property Inspections" or "ICU Travel Nursing". It holds the requirements, work definitions, and rate card that apply to everyone on it.
PayeeEngagementAssignmentOne Payee assigned to one Engagement. It carries the classification (engagementType), dates, overrides, the requirement instances, and whether the Payee can be paid through it.
PayerEngagement(client project)The other direction: a commercial engagement with a client who pays you. See PayerEngagement below.

Wingspan's February 2026 terminology update renamed "Engagement Type" to Engagement and "Contractor Engagement" to Assignment. In the API, the Engagement is Engagement and the Assignment is PayeeEngagement.

A Payee can hold several PayeeEngagements at once, including under different Engagements. Payables, requirements, and eligibility are tracked per PayeeEngagement, which is why "can I pay this person?" is answered on the engagement and not on the Payee.

Create an engagement template

POST /v3/payments/engagements:

curl -X POST https://api.wingspan.app/v3/payments/engagements \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "ICU Travel Nursing",
    "description": "13-week ICU contracts, west region",
    "externalId": "PRJ-ICU-WEST",
    "engagementType": "Contractor",
    "requirementDefinitionIds": ["oP8lYOzdKQy7H_mNrx9Uf9"]
  }'
// 201 Created (trimmed)
{
  "id": "Fj9ZSOWd3LdiamTunQ5x98",
  "name": "ICU Travel Nursing",
  "externalId": "PRJ-ICU-WEST",
  "status": "Active",
  "isDefault": false,
  "engagementType": "Contractor",
  "requirementIds": ["oP8lYOzdKQy7H_mNrx9Uf9"],
  "requirementDefinitions": [
    { "requirementDefinitionId": "oP8lYOzdKQy7H_mNrx9Uf9", "blockingMode": "BlocksPayment" }
  ],
  "workDefinitionIds": [],
  "events": { "createdAt": "2026-09-24T15:20:00Z" }
}
FieldWhat it's for
nameRequired. Unique per Account; a duplicate name returns 409 ResourceConflict.
description, externalId, metadataYour labels and IDs. A duplicate externalId returns 409 ResourceConflict.
engagementTypeContractor, Employee, EmployeeOfRecord, or AgentOfRecord. See Engagement types and payee context.
requirementDefinitionIdsRequirement definitions every Payee on this engagement must complete. See Requirements and eligibility. An Employee template also gets the employee tax forms and employment eligibility (I-9) requirements added automatically.
workDefinitions[{ "workDefinitionId": "...", "rateCalculationId": "..." }] to turn on work logs for this engagement. See Work logs and rate cards.
rateCardIdBase rate card for this engagement.
worksiteId, isRemoteLocation, for Employee work: a worksite for onsite work, or isRemote: true for remote work.

Your Account can have one default Engagement (isDefault: true). Payables and batch items that name a Payee without an engagement are issued under it, and a new Contractor Payee is placed on it. Create it with isDefault: true on a Contractor template. Any other type returns 422, and a second default returns 409. The default can't be archived or deleted.

Attach or remove requirements later

These sub-resources are write-only. Read the result back with GET /v3/payments/engagements/{engagementId} and look at requirementDefinitions. blockingMode controls what an unfinished requirement blocks:

blockingModeEffect of an incomplete requirement
BlocksPaymentThe Payee can't be paid through this engagement.
BlocksEligibilityButNotPaymentAccepted, but it currently behaves like BlocksPayment: the engagement is not eligible and payment is held until the requirement is complete. Only NotBlocking lets an incomplete requirement pass.
NotBlockingTracked, but blocks nothing.

The same pattern works for work definitions: POST /v3/payments/engagements/{engagementId}/work-definitions with {"workDefinitionId": "..."} to attach, and DELETE /v3/payments/engagements/{engagementId}/work-definitions/{workDefinitionId} to detach.

Update, archive, and delete

  • PATCH /v3/payments/engagements/{engagementId} updates name, description, externalId (empty string clears it), rateCardId (null removes it, except on an Employee template), worksiteId, isRemote, earnedWageAccess, and metadata. Omitted fields stay the same.
  • POST .../archive sets status to Archived. A default engagement, or one with PayeeEngagements that aren't archived, returns 409.
  • POST .../restore sets it back to Active.
  • DELETE /v3/payments/engagements/{engagementId} removes the template. It returns 409 with detailCode: payments.EngagementNotDeletable if it is the default engagement or any PayeeEngagement still references it.

List templates with GET /v3/payments/engagements, filtering by status (anyOf), externalId, isDefault, or requirementDefinitionIds (anyOf), and sorting by one of sort[name] or sort[updatedAt]. The requirementDefinitionIds filter finds every template that uses a requirement definition, which is useful before you retire that definition.

Assign a payee to an engagement

POST /v3/payments/payees/{payeeId}/engagements creates the PayeeEngagement. The Payee doesn't need to be linked first.

curl -X POST https://api.wingspan.app/v3/payments/payees/M9ISYbzJElXs4zIHv76rjT/engagements \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "engagementId": "Fj9ZSOWd3LdiamTunQ5x98",
    "engagementType": "Contractor",
    "startDate": "2026-10-05",
    "endDate": "2027-01-02",
    "externalId": "ASSIGN-77120",
    "jobTitle": "ICU RN"
  }'
// 201 Created (trimmed)
{
  "id": "ru9zEafz6i4WRlIOA10wAk",
  "engagementId": "Fj9ZSOWd3LdiamTunQ5x98",
  "engagementName": "ICU Travel Nursing",
  "payeeId": "M9ISYbzJElXs4zIHv76rjT",
  "engagementType": "Contractor",
  "status": "Created",
  "startDate": "2026-10-05",
  "endDate": "2027-01-02",
  "paymentsEligibility": "NotEligible",
  "areAllRequirementsComplete": false,
  "requirementIds": ["CkAmZjWbwGy8H6bX6a4lA1"]
}

Creating the PayeeEngagement provisions one requirement instance per requirement definition on the template. requirementIds lists them. engagementId and engagementType are required. engagementName lets you override the displayed name, for example with a job title. For Employee work, set worksiteId to a worksite for onsite work, or leave it out for remote work.

engagementType must match the template's type, or you get 422 with detailCode: payments.PayeeEngagementTypeMismatch. A Payee can hold one PayeeEngagement per Engagement; a second one returns 409. A Deactivated Payee returns 404. The type must also fit the Payee's context, or you get 409 ResourceConflict (see the next section).

Engagement types and payee context

engagementType is one of the EngagementType values:

engagementTypeWhat it is
ContractorA 1099 contractor or vendor relationship. No withholding.
EmployeeA W-2 employee on payroll, with withholding.
EmployeeOfRecordAn employer-of-record engagement, where Wingspan or a partner is the legal employer.
AgentOfRecordAn agent-of-record engagement, where an agent administers the engagement for the payer.

Every Payee has a context, Contractor or Employee, that you set when you create it. Only an Employee engagement fits the Employee context, and the other types fit Contractor. Creating a PayeeEngagement whose type doesn't fit the Payee's context returns 409 ResourceConflict. A Payee created before context existed takes its context from its first engagement.

Lifecycle

StatusMeaning
CreatedAssigned, not yet in service.
ActivatedIn service.
SuspendedPaused. Can be reactivated.
TerminatedEnded. Terminal.
stateDiagram-v2
    [*] --> Created: POST /payees/{payeeId}/engagements
    Created --> Activated: POST /activate
    Activated --> Suspended: POST /suspend
    Suspended --> Activated: POST /activate
    Activated --> Terminated: POST /terminate
    Suspended --> Terminated: POST /terminate
    Created --> [*]: DELETE
ActionCallNotes
ActivatePOST .../engagements/{payeeEngagementId}/activateFrom Created or Suspended. Returns 409 if eligibility is incomplete.
SuspendPOST .../suspendFrom Activated. Optional {"reason": "..."}.
TerminatePOST .../terminateFrom Activated or Suspended. Permanent.
DeleteDELETE .../engagements/{payeeEngagementId}Only while Created, and only if no invoices or work logs reference it. Otherwise 409. Use terminate once an engagement has been in service.

All four paths start with /v3/payments/payees/{payeeId}/engagements/{payeeEngagementId}.

When a lifecycle action returns 409, detailCode tells you why, for example payeeEngagement.EligibilityIncomplete (requirements still open) or payeeEngagement.InvalidLifecycleTransition (wrong current status). Use it for logs and support; branch your code on code.

Changing a Payee's classification

engagementType can't be changed on an existing PayeeEngagement. To move someone from one classification to another, terminate the current PayeeEngagement and create a new one with the new type. The Payee, its payment history, and your externalId mapping stay the same.

Moving between contractor and employee work also changes the Payee's context:

  1. Terminate (or, if never in service, delete) every Created or Activated engagement of the current context.
  2. Call POST /v3/payments/payees/{payeeId}/change-context with the new context. See Payees.
  3. Create the new PayeeEngagement.

A Suspended engagement doesn't block the change, but it can't be resumed afterward, so terminate it if you're done with it.

An Employee engagement needs W-2 payroll turned on for your account and, if it has a worksite, one in a state enabled on your employer payroll profile. You can assign an employee before they link an account, but their tax elections aren't available until they do. See Add and assign employees. EmployeeOfRecord engagements have extra prerequisites, including a linked Account for the Payee. See Payroll runs.

Read engagements

One Payee's engagements: GET /v3/payments/payees/{payeeId}/engagements, filterable by externalId.

All engagements across your Payees: GET /v3/payments/payee-engagements, filterable by status (eq, anyOf), engagementType, engagementId (eq, anyOf), and externalId. To query with a request body, see Search engagements. To query with a request body, see Search engagements.

curl -G https://api.wingspan.app/v3/payments/payee-engagements \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  --data-urlencode "filter[engagementId][eq]=Fj9ZSOWd3LdiamTunQ5x98" \
  --data-urlencode "filter[status][anyOf][]=Created" \
  --data-urlencode "filter[status][anyOf][]=Activated"

One engagement with its requirements inline: GET /v3/payments/payee-engagements/{payeeEngagementId}?expand=Requirements. Each inlined requirement carries the blockingMode this engagement applies to it. expand=WorkDefinitions inlines the template's work definitions. The payee-scoped read, GET /v3/payments/payees/{payeeId}/engagements/{payeeEngagementId}, also accepts expand=EarnedWageAccess. Expansions work on single reads only, not on lists. Any other expand value returns 422.

Update an engagement

PATCH /v3/payments/payees/{payeeId}/engagements/{payeeEngagementId} or the flat PATCH /v3/payments/payee-engagements/{payeeEngagementId} updates startDate, endDate (send null to clear), jobTitle, engagementName, worksiteId, overrides, quickbooksVendorId, quickbooksExpenseAccountId, and metadata.

overrides.isAutoPayEnabled overrides the Payee's defaults.isAutoPayEnabled for this engagement only. overrides.paymentTermsDays overrides the Payee's defaults.paymentTermsDays (send null to clear it).

To bind a rate card that overrides the template's for one Payee, use PATCH .../engagements/{payeeEngagementId}/rate-card with {"rateCardId": "..."}.

PayerEngagement for client relationships

If you also bill clients, a PayerEngagement records a commercial engagement with a client who pays you. Create it against your Payer record with POST /v3/payments/payers/{payerId}/engagements, using type: Commercial, an engagementId from your own Account, and a startDate.

A PayerEngagement is its own resource with its own ID and fields. It isn't a mirror of a PayeeEngagement, and it doesn't carry worker requirements or eligibility. See Invoices overview.

automaticCollectionMode (Inherit, Enabled, or Disabled) on a PayerEngagement overrides the Payer's isAutomaticInvoiceCollectionEnabled for that engagement, so Wingspan collects (or doesn't) on your opened invoices without a pay call for each. See Collect payment. Auto-pay on the paying side is different: it still comes from Payee.defaults.isAutoPayEnabled and the PayeeEngagement's overrides.isAutoPayEnabled.

Webhooks

You can subscribe to PayeeEngagement.EligibilityChanged. It fires each time a PayeeEngagement's paymentsEligibility flips between Eligible and NotEligible, and routes to the payer Account. Engagement.*, PayeeEngagement.*, and PayerEngagement.* events are also available. To list engagements by filter, use GET /v3/payments/payee-engagements or search.

Common mistakes

  • Deleting an engagement that's been in service. DELETE only works in Created. Use terminate.
  • Trying to change engagementType. Terminate and create a new PayeeEngagement. Change the Payee's context first if you're moving between contractor and employee work.
  • Reading requirements from the template. The template lists requirement definitions. The Payee's actual progress is on the PayeeEngagement's requirement instances.
  • Activating before requirements are done. activate returns 409 with payeeEngagement.EligibilityIncomplete until blocking requirements are complete.

Related


Did this page help you?