Quickstart: invite and pay a contractor

Invite a contractor, create a payable, pay it, and get a webhook when it's paid, using the Wingspan V3 API.

Use the V3 API to subscribe to payment webhooks, invite a contractor, create a payable, and pay it from a new sandbox.

In the examples, Northwind Staffing (the payer) pays Priya Shah (a contractor) $1,250.00 for a week of site inspections. Every request goes to the sandbox, https://stagingapi.wingspan.app. Switch the host to https://api.wingspan.app when you go live.

If you used the V1 guide, this replaces POST /payments/collaborator and POST /payments/payable. Collaborators are called payees in V3.

Before you begin

Complete the following:

  1. Set a token for Northwind's Account in $WINGSPAN_TOKEN. Use an API key. See Environments and authentication.
  2. Link a verified bank account. Northwind's Account needs an external bank account with status: Verified. Link it in the Wingspan app or through /v3/finance/external-bank-accounts. See Payroll settings and funding.
  3. Enable contractor payments for your Account. Otherwise, payable writes return 403 Forbidden with detailCode: payments.AccountNotEntitled. Contact Wingspan if you receive this response.
  4. Configure an HTTPS endpoint to receive webhooks. In the sandbox, you can use a tunnel to your laptop.

Send an Idempotency-Key on every POST below. If a request times out, resend it with the same key and body, and you'll get the original result instead of a duplicate.

The flow has eight steps:

sequenceDiagram
  participant You as Your server (Northwind)
  participant WS as Wingspan
  participant Priya as Priya (contractor)
  You->>WS: 1. POST /v3/platform/webhooks
  You->>WS: 2. POST /v3/payments/payees
  You->>WS: 3. POST /v3/payments/payees/{id}/invite
  WS->>Priya: Invite email with one-time link
  Priya->>WS: Signs in, links her Account, completes requirements, adds payout method
  You->>WS: 4. POST /v3/payments/payables
  You->>WS: 5. POST /v3/payments/payables/{id}/open
  You->>WS: 6. PATCH /v3/payments/payables/{id}/workflow-status
  You->>WS: 7. POST /v3/payments/payables/{id}/pay
  WS-->>You: Payable.Paid webhook

Step 1: Subscribe to payment webhooks

Create the subscription before you create a payable so you receive its payment events. POST /v3/platform/webhooks takes your URL, the events to receive, and the Account whose events to receive.

curl -X POST https://stagingapi.wingspan.app/v3/platform/webhooks \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://hooks.northwind.example.com/wingspan",
    "subscribedEvents": ["Payable.Paid", "Payable.PartiallyPaid", "Payable.Returned"],
    "scope": { "type": "Account", "id": "Nw7kQp2LmX9vRt4ZcY1bHd", "shouldIncludeDescendants": false },
    "description": "Quickstart payment events"
  }'
// 201 Created (trimmed)
{
  "id": "Wh3nQ8vK2mZr_Tp6LcYx0b",
  "status": "Active",
  "url": "https://hooks.northwind.example.com/wingspan",
  "subscribedEvents": ["Payable.Paid", "Payable.PartiallyPaid", "Payable.Returned"],
  "scope": { "type": "Account", "id": "Nw7kQp2LmX9vRt4ZcY1bHd", "shouldIncludeDescendants": false },
  "secret": "whsec_..."
}

Important: Save secret now. It's returned only on this response, and you need it to verify every webhook Wingspan sends you. If you lose it, rotate it with POST /v3/platform/webhooks/{webhookId}/rotate-secret.

GET /v3/platform/webhook-event-types lists every event you can subscribe to. See Create a subscription.

Step 2: Create the payee

A payee is Northwind's record of Priya. POST /v3/payments/payees needs her email and a context, which says what kind of worker she is to you: Contractor for a 1099 contractor or vendor, or Employee for a W-2 employee. Add externalId so you can find her by your own ID later.

curl -X POST https://stagingapi.wingspan.app/v3/payments/payees \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "email": "[email protected]",
    "context": "Contractor",
    "externalId": "NW-CONTRACTOR-0042",
    "profile": { "displayName": "Priya Shah" }
  }'
// 201 Created (trimmed)
{
  "id": "Ra8vN3xQe_T1uLm6KpWz2c",
  "accountId": "Nw7kQp2LmX9vRt4ZcY1bHd",
  "externalId": "NW-CONTRACTOR-0042",
  "email": "[email protected]",
  "context": "Contractor",
  "status": "Activated",
  "events": { "createdAt": "2026-09-24T15:02:11Z" }
}

Store id. It's the payee's permanent ID, and every payable you create for Priya uses it. payeeAccountId is missing because Priya hasn't linked her own Account yet. That's expected.

If externalId is already used by another payee, you get 409 ResourceConflict. Creates are never upserts: look up the existing payee with GET /v3/payments/payees?filter[externalId][eq]=NW-CONTRACTOR-0042.

Step 3: Invite the payee

POST /v3/payments/payees/{payeeId}/invite emails Priya a one-time link. The body is optional. Add customMessage to include a note.

curl -X POST https://stagingapi.wingspan.app/v3/payments/payees/Ra8vN3xQe_T1uLm6KpWz2c/invite \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "customMessage": "Welcome to Northwind. Finish setup before your first inspection." }'
// 200 OK (trimmed)
{
  "id": "Ra8vN3xQe_T1uLm6KpWz2c",
  "status": "Activated",
  "linkRequestStatus": "Pending"
}

After she receives the invite, Priya completes three tasks in Wingspan:

  1. She opens the link, signs in or creates a login, and accepts Northwind as a payer. That links the payee to her Account, and linkRequestStatus becomes Linked.
  2. She completes the requirements on your engagement, such as tax information.
  3. She adds a payout method so Wingspan knows where to send her money.

You can check progress with GET /v3/payments/payees/{payeeId}. In the sandbox, create the payee with an email address you control so you can play Priya's part yourself.

See Invite a payee and Requirements and eligibility.

Step 4: Create the payable

POST /v3/payments/payables needs the payee, a currency, a due date, and at least one line item. Each line item has either totalCost, or quantity and unitCost. You don't send the total: Wingspan adds up the line items.

curl -i -X POST https://stagingapi.wingspan.app/v3/payments/payables \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "payeeId": "Ra8vN3xQe_T1uLm6KpWz2c",
    "currency": "USD",
    "dueDate": "2026-10-02",
    "externalId": "NW-PAY-2026-0917",
    "lineItems": [
      { "description": "Site inspections, week of Sep 21", "lineItemType": "Services", "quantity": 25, "unitCost": 50.00, "unit": "hours" }
    ]
  }'
HTTP/2 201
ETag: "9f2c4e1ab7d35c08"
// (trimmed)
{
  "id": "Bq4tZ7wJ0rXn_Hs2VdLe9m",
  "payeeId": "Ra8vN3xQe_T1uLm6KpWz2c",
  "payeeEngagementId": "Em5cU1yR8kPa_Wq3NtGf6z",
  "status": "Created",
  "payerApprovalStatus": "Pending",
  "currency": "USD",
  "amount": 1250.00,
  "dueDate": "2026-10-02",
  "events": { "createdAt": "2026-09-24T16:40:03Z" }
}

Keep the ETag header. You need it in the next step. payeeEngagementId is the engagement Wingspan used: when you pass only payeeId, it uses your default engagement for this payee and creates one if needed.

A payable in Created is a draft. Priya can't see it yet.

Step 5: Open the payable

Opening finalizes the payable and makes it visible to Priya. POST /v3/payments/payables/{payableId}/open requires If-Match with the ETag from step 4, which stops you from opening a payable someone else changed after you read it.

curl -i -X POST https://stagingapi.wingspan.app/v3/payments/payables/Bq4tZ7wJ0rXn_Hs2VdLe9m/open \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H 'If-Match: "9f2c4e1ab7d35c08"' \
  -H "Idempotency-Key: $(uuidgen)"
// 200 OK (trimmed)
{
  "id": "Bq4tZ7wJ0rXn_Hs2VdLe9m",
  "status": "Opened",
  "events": { "createdAt": "2026-09-24T16:40:03Z", "openedAt": "2026-09-24T16:41:30Z" }
}

This is the step where Priya's onboarding matters. If she hasn't completed the engagement's requirements, you get 409 with code: EligibilityBlocked and detailCode: payments.EngagementNotPaymentsEligible. Check her status with GET /v3/payments/payee-engagements/{payeeEngagementId}: open the payable once paymentsEligibility is Eligible.

If you get 412 with code: PreconditionFailed, the payable changed since you read it. Fetch it again, take the new ETag, and retry.

Step 6: Approve the payable

Approval is a separate field from status, so your team can approve, hold, or decline a payable without changing where it is in its lifecycle. PATCH /v3/payments/payables/{payableId}/workflow-status sets it.

curl -X PATCH https://stagingapi.wingspan.app/v3/payments/payables/Bq4tZ7wJ0rXn_Hs2VdLe9m/workflow-status \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "payerApprovalStatus": "Approved" }'
// 200 OK (trimmed)
{ "id": "Bq4tZ7wJ0rXn_Hs2VdLe9m", "status": "Opened", "payerApprovalStatus": "Approved" }

Step 7: Pay the payable

First find the bank account to pay from. GET /v3/finance/external-bank-accounts lists Northwind's accounts. Pick one with status: Verified.

curl https://stagingapi.wingspan.app/v3/finance/external-bank-accounts \
  -H "Authorization: Bearer $WINGSPAN_TOKEN"
// 200 OK (trimmed)
{
  "data": [
    { "id": "Kd9sLw2Tq7Bv_Xz4MnHr1p", "status": "Verified", "institutionName": "Example Bank", "accountNumberMask": "6789", "currency": "USD" }
  ],
  "pagination": { "nextPageToken": "" }
}

Now pay. POST /v3/payments/payables/{payableId}/pay debits that bank account and pays Priya's payout method. Idempotency-Key is required here.

curl -X POST https://stagingapi.wingspan.app/v3/payments/payables/Bq4tZ7wJ0rXn_Hs2VdLe9m/pay \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5d0c7a52-8f0e-4d7a-9b1e-2a6f3c9e41b7" \
  -d '{
    "method": "Ach",
    "fundingSource": { "type": "ExternalBankAccount", "id": "Kd9sLw2Tq7Bv_Xz4MnHr1p" }
  }'
// 200 OK (trimmed)
{
  "id": "Bq4tZ7wJ0rXn_Hs2VdLe9m",
  "status": "PaymentInTransit",
  "payerApprovalStatus": "Approved",
  "amount": 1250.00,
  "currency": "USD"
}

Leave out amount to pay the full payable (partial amounts aren't accepted here). The first successful call locks in the amount, funding source, and payout routes. If you retry, keep the same Idempotency-Key and the same fundingSource: changing the source on a retry returns 409.

Important: Store the Idempotency-Key you used for the pay call alongside the payable until you've confirmed the result. It's what makes a retry after a timeout safe.

Step 8: Receive the webhook

When the payable is paid, Wingspan POSTs a Payable.Paid event to your URL:

POST /wingspan HTTP/1.1
Host: hooks.northwind.example.com
Content-Type: application/json
Wingspan-Signature: t=1758735600,v1=5f2b8c...
{
  "id": "88b676627825e5304f7bd2bf86aa70196a278029411b97531cdde290a63edcbc",
  "type": "Payable.Paid",
  "routingSubject": { "type": "Account", "id": "Nw7kQp2LmX9vRt4ZcY1bHd" },
  "occurrenceId": "1078a97c23e492f409e76dbd18217d8104b5cd0d8fe7958d2aa96a5d273eb795",
  "createdAt": "2026-09-25T14:20:01Z",
  "eventCreatedAt": "2026-09-25T14:19:58Z",
  "apiVersion": "2026-03-27",
  "data": { "id": "Bq4tZ7wJ0rXn_Hs2VdLe9m" },
  "actor": { "kind": "ServiceAccount", "disclosure": "Redacted" }
}

Handle it in this order:

  1. Verify the signature before you parse the body. Compute HMAC-SHA256 with your subscription secret over the text {t}. followed by the exact raw request body, and compare it with each v1 value in constant time. Reject timestamps more than 300 seconds from your clock. See Verify signatures.
  2. Deduplicate on id. Wingspan can send the same event more than once.
  3. Fetch the payable with GET /v3/payments/payables/{payableId} using data.id. The event tells you something changed. The resource tells you the details.
  4. Return a 2xx response promptly. Process slow work afterward.

Payable.Paid means Wingspan's records show the payable as paid. It doesn't mean the money is in Priya's bank account yet, and an ACH payment can still come back later. If that happens you'll get Payable.Returned, and the payable's status becomes Returned. To pay again, create a new payable. See Paid vs DepositConfirmed.

Webhook delivery is best effort. If your endpoint is down, you can read everything you missed from GET /v3/platform/events for 30 days. See Recover missed events.

Confirm it worked

curl https://stagingapi.wingspan.app/v3/payments/payables/Bq4tZ7wJ0rXn_Hs2VdLe9m \
  -H "Authorization: Bearer $WINGSPAN_TOKEN"

status is Paid and events.paidAt is set.

What can go wrong

StepResponseWhat it meansWhat to do
Any write403 Forbidden, detailCode: payments.AccountNotEntitledContractor payments aren't turned on for this Account.Contact Wingspan.
1422 ValidationError on subscribedEvents[n]That event isn't available to subscribe to for this scope.Check GET /v3/platform/webhook-event-types.
2409 ResourceConflictThe externalId or email already belongs to one of your payees, or the email matches a payee with a different context.Look it up with filter[externalId][eq] or filter[email][eq] and reuse it.
2422 ValidationError on contextcontext is missing or isn't Contractor or Employee.Add context.
4422 ValidationErrorA required field is missing or a line item has a field it doesn't accept.Check errors[].
5409 EligibilityBlockedThe payee hasn't finished the engagement's requirements.Wait for paymentsEligibility: Eligible, then retry.
5412 PreconditionFailedYour If-Match ETag is out of date.Re-read the payable and retry with the new ETag.
7403 StepUpMfaRequiredPaying needs recent MFA for this session.Complete the challenge in extensions, then retry.
7404 ResourceNotFoundThe funding source isn't one of this Account's saved bank accounts or cards.Use an id from GET /v3/finance/external-bank-accounts.
7409A different funding source was sent on a retry, or the payable isn't in a payable state.Retry with the original body, or check the payable's status and approval.

Error bodies all share one shape. Branch on code. See Errors.

Next steps

  • Pay many contractors at once with a payroll run, which pays every approved payable you select in one funding movement.
  • Import payees or payables in bulk with batches.
  • Put requirements on your work with engagements.
  • Create a payable covers every payable option.

Did this page help you?