Integrate an ATS or CRM
Connect an applicant tracking system or CRM to Wingspan V3. Object mapping, the calls to make on hire, how to sync status back, and common pitfalls.
This guide shows how to connect an applicant tracking system (ATS) or customer relationship management (CRM) system to Wingspan: which Wingspan objects correspond to candidates, placements, and clients; which calls to make when someone is hired; and how to send onboarding and payment status back. It's a pattern guide. You write the middleware (or use an integration tool) that calls the Wingspan API.
Where Wingspan fits
Staffing and contingent-work businesses usually run several systems:
- Recruiting (ATS): finds candidates and tracks hires.
- Client relationships (CRM): tracks the companies you bill.
- Assignment management: tracks who works where.
- Wingspan: onboards contractors, collects tax and payout information, pays them, and files 1099s.
The integration has two directions. Inbound, a hire or placement in the ATS creates or updates records in Wingspan. Outbound, Wingspan status (claimed, ready to pay, paid) flows back so recruiters and account managers know when a contractor can start.
Map your objects
| ATS or CRM object | Wingspan object | Fields that matter |
|---|---|---|
| Hired candidate | Payee (called a collaborator in V1) | externalId = candidate ID, email, context (Contractor or Employee), profile.displayName |
| Custom candidate fields (license state, recruiter, cost center) | Custom field with resourceType: Payee | Values set with PATCH /v3/payments/payees/{payeeId}/custom-fields |
| Job or placement type | Engagement (a reusable template) | name, externalId, requirements |
| A specific placement | PayeeEngagement | engagementId, engagementType, externalId = placement ID, startDate, endDate, jobTitle |
| Client company you bill | Payer | externalId = CRM account ID, email |
| Client company that runs its own payroll through you | A child Account | externalId = CRM account ID |
| Timesheet or completed shift | Payable or work log | externalId = timesheet ID, line items |
| Client bill | Invoice | externalId = your invoice ID |
Put your system's ID in externalId, never in metadata. externalId is a first-class field: it's echoed on every read, filterable with filter[externalId][eq], and unique per Account and resource type, so it's how your middleware finds the Wingspan record again. Use metadata for extra tags you don't look records up by.
Inbound: a candidate is hired
sequenceDiagram
participant ATS
participant MW as Your middleware
participant WS as Wingspan API
ATS->>MW: Candidate status = Hired
MW->>WS: POST /v3/payments/payees (externalId = candidate ID)
MW->>WS: PATCH /payees/{id}/custom-fields
MW->>WS: POST /payees/{id}/engagements (externalId = placement ID)
MW->>WS: POST /payees/{id}/invite
WS-->>ATS: (via middleware) payeeId stored on candidate
1. Create the payee
Create a payee. Derive the Idempotency-Key from the candidate ID so a retried webhook from your ATS can't create a second payee.
curl -X POST https://api.wingspan.app/v3/payments/payees \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Idempotency-Key: ats-hire-cand-88213" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"externalId": "cand-88213",
"context": "Contractor",
"profile": { "displayName": "Priya Shah" }
}'// 201 Created (trimmed)
{
"id": "Py3mQw7kLx2tRb9nVd4sHa",
"externalId": "cand-88213",
"email": "[email protected]",
"context": "Contractor",
"payeeAccountId": null
}context is required. Use Contractor for 1099 placements and Employee for W-2 hires. It has to fit the engagement type you place the payee on in step 3.
If the candidate was already sent (for example, rehired), you get 409 ResourceConflict. Create is never an upsert. Look up the existing payee and continue:
curl -G https://api.wingspan.app/v3/payments/payees \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
--data-urlencode "filter[externalId][eq]=cand-88213"2. Copy custom fields
Define each field once with Create a custom field (resourceType: Payee, a key, and a dataType such as String). Then set values per payee with Update payee custom fields:
curl -X PATCH https://api.wingspan.app/v3/payments/payees/Py3mQw7kLx2tRb9nVd4sHa/custom-fields \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "licenseState": "NY", "recruiter": "Jordan Lee" }'Send null for a key to clear it.
3. Place the payee on an engagement
Create an Engagement template once per job type with POST /v3/payments/engagements (it holds the onboarding requirements for that kind of work). Then attach the payee with Create a payee engagement:
curl -X POST https://api.wingspan.app/v3/payments/payees/Py3mQw7kLx2tRb9nVd4sHa/engagements \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Idempotency-Key: ats-place-pl-40177" \
-H "Content-Type: application/json" \
-d '{
"engagementId": "En5tRk2wQp8xLm3nVb6dHs",
"engagementType": "Contractor",
"externalId": "pl-40177",
"jobTitle": "Travel RN",
"startDate": "2026-10-05"
}'The payee engagement carries the requirements the contractor must complete before you can pay them for this work.
4. Invite the contractor
Invite the payee. Wingspan emails the contractor, who signs up, completes onboarding, and accepts you as a payer. See Invite a payee.
Hiring many at once
For a migration or a large hiring wave, use a PayeeImport batch instead of one call per candidate. Each item matches an existing payee by payeeId, then externalId, then email, and updates it; otherwise it creates the payee and invites it. Set configuration.context (required) to the context new payees are created with, and configuration.engagementId to place every payee on one engagement. See Batches and bulk operations.
Outbound: send status back to the ATS or CRM
Recruiters want to know when a contractor has claimed the invite and is ready to be paid. Webhooks for payee linking, engagement eligibility, and requirement completion aren't available yet, so poll on a schedule:
| You want to know | Poll |
|---|---|
| The contractor claimed the invite | GET /v3/payments/payees?filter[linkRequestStatus][eq]=Linked |
| The contractor can be paid for a placement | GET /v3/payments/payee-engagements?filter[externalId][eq]=pl-40177, then read paymentsEligibility (Eligible or NotEligible) and areAllRequirementsComplete |
| What changed on a payee | GET /v3/payments/payees/{payeeId}/events, newest first |
Payment status is available as webhooks. Subscribe to Payable.Paid, Payable.Returned, and, if you bill clients through Wingspan, Invoice.Paid and Invoice.PaymentFailed. Map each event back with the resource's externalId: fetch the payable or invoice by data.id and read externalId. See Webhooks overview.
Example: write verification status to a CRM vendor record
A company that uses Wingspan to verify vendors at registration keeps its CRM as the system of record. Its middleware:
- Creates the payee with
externalIdset to the CRM vendor record ID. - Polls the payee's engagement until
paymentsEligibilitychanges, or until a requirement needs attention. - Writes the result and the timestamp back to the CRM record, looking it up by
externalId.
Because externalId comes back on every read, the middleware needs no lookup table of its own.
Pitfalls
- Treating create as upsert. A duplicate
externalIdreturns409. Handle it by looking the record up, not by retrying with a new ID. - Reusing an
Idempotency-Keywith a different body. That returns409 IdempotencyKeyConflict. Tie the key to the source event, not to the candidate alone, when the same candidate can generate different requests. The response cache lasts 24 hours and is best effort. See Idempotency. - Polling too hard. Filter on
linkRequestStatus,status, orexternalIdinstead of listing everything, and page withpage[token]. Reads cost from theaccount-readbucket. See Rate limiting. - Assuming
Payable.Paidmeans money arrived.Paidis Wingspan's internal state. It doesn't mean the contractor's bank has received the funds. See Paid vs DepositConfirmed. - Ignoring
errors[]. Validation failures return422with per-fielderrors[]. Log them against the source record so someone can fix the data in the ATS. See Errors.
Related pages
Updated 10 days ago