Collect payment on an invoice
How a payer pays a V3 invoice by bank debit, saved card, payment link, or transfer, and how to collect automatically or record off-platform payments.
This page explains the ways an invoice can be paid through the V3 API, from both sides: you, the
payee who issued the invoice, and your client, the payer. It covers asking a client to authorize
bank debits or save a card online, debit authorizations (mandates), payment links for clients who
never log in, automatic collection, and how to track a payment until it settles.
Choose how the payer pays
| Situation | How it works | Main calls |
|---|---|---|
| You want your client to authorize ACH debits online | You store their bank account and send a mandate request. They confirm the micro-deposits and accept the terms on a page you or Wingspan host. | POST /v3/payments/mandate-requests, then the payer uses /v3/payments/mandate-sessions |
| You want to keep your client's card on file | You create a card setup. They enter the card in a hosted frame and accept the terms. | POST /v3/payments/payer-card-setups, then the payer uses /v3/payments/payer-card-sessions |
| Your client already signed a debit authorization on paper or in your own system | You store their bank account, record the authorization as a mandate, and collect. | POST /v3/finance/external-bank-accounts, POST /v3/payments/mandates, POST .../pay |
| Your client has its own linked Wingspan Account | The payer pays from its own saved card or bank account. | POST .../pay, called by the payer |
| Your client has no Wingspan login and wants to pay online once | You create a payment link. The payer pays by card or bank debit in a hosted flow. | POST .../payment-links, then the payer uses /v3/payments/invoice-payment-intents |
| Your client wants to send a wire or ACH credit | You give them the bank transfer instructions for the Payer. | GET /v3/payments/payers/{payerId}/bank-transfer-processing-account |
| Your client paid by check, cash, or outside Wingspan | You record the payment so the invoice closes. | POST .../pay-off-platform |
The invoice's acceptedPaymentMethods (Ach, Credit, Manual) says which of these the payer
may use. See Components of an invoice.
Important: Collecting through
POST /v3/payments/invoices/{invoiceId}/payis turned on per
Account. If it isn't on for yours, the call returns403. Contact support to turn it on.
Payment methods and who owns them
A payment method is the bank account or card the money comes from. Two kinds can fund an invoice
payment:
| Type | Resource | Notes |
|---|---|---|
ExternalBankAccount | /v3/finance/external-bank-accounts | Needs a mandate (a debit authorization) before you can debit it. |
PaymentCard | /v3/payments/payment-cards | Saved through a hosted card-capture flow. Raw card numbers never go through the API. A card you save for a client through a card setup is a PaymentCard tied to that Payer. |
Each side owns its own methods and can't see the other side's. When you store your client's bank
account or card yourself, it's yours to manage, and it's tied to that one Payer record. When a
linked payer saves its own card or bank account, it's the payer's.
GET /v3/payments/payers/{payerId}/payment-methods
returns only the methods the caller owns for that relationship.
Once a Payer is linked, Payer.settingsAuthority decides whose payment setup wins. You set it on
the Payer record with PATCH /v3/payments/payers/{payerId}:
| Value | Effect |
|---|---|
PreferPayerAccountSupplied (default) | Use the payer's own setup. If the payer hasn't set one up, fall back to yours, so linking doesn't break collection. |
AlwaysPayerAccountSupplied | Use only the payer's setup. |
AlwaysPayeeAccountSupplied | Use only yours. |
While a Payer is unlinked, your setup always applies.
Debit a payer's bank account
Use this to collect from your client's bank account by ACH debit. You need their bank account and
their authorization (a mandate) before you can debit it. Bank debit currently supports business
checking accounts.
There are two ways to get the authorization:
- Ask for it online. Send a mandate request.
Your client confirms the micro-deposits and accepts the debit terms themselves. You never see the
deposit amounts. - Record one you already have. If your client signed an authorization outside Wingspan, upload it
and create the mandate yourself (step 2 below).
1. Store the payer's bank account
Create the account with
POST /v3/finance/external-bank-accounts
and subject set to the Payer, so it belongs to that one relationship and can never be used as your
own payout destination. This call requires elevated authentication. See
Payment and payout methods for verification
options.
curl -X POST https://api.wingspan.app/v3/finance/external-bank-accounts \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"subject": { "type": "Payer", "id": "Pq7Lm2Xv9RtK4sWb8NcY3d" },
"routingNumber": "000000000",
"accountNumber": "000000000",
"accountType": "Checking",
"accountHolderType": "Business",
"accountHolderName": "Northwind Staffing LLC"
}'Next, start micro-deposit verification by sending { "verificationMethod": "MicroDeposit" } to
PATCH /v3/finance/external-bank-accounts/{bankAccountId}.
The account stays Pending until someone enters the two deposit amounts:
- If you're sending a mandate request, leave it
Pending. Your client enters the amounts on the
signing page. - If you know the amounts, verify it yourself with
POST /v3/finance/external-bank-accounts/{bankAccountId}/verify.
To find a Payer's debit accounts later, list with
filter[subjectType][eq]=Payer&filter[subjectId][eq]=<payerId>.
2. Record the authorization as a mandate
Skip this step if you're sending a mandate request.
Accepting the request creates the mandate for you.
Upload the signed authorization to the vault (POST /v3/compliance/vault-files), then call
POST /v3/payments/mandates. This call
requires Idempotency-Key and elevated authentication.
curl -X POST https://api.wingspan.app/v3/payments/mandates \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"subject": { "type": "Payer", "id": "Pq7Lm2Xv9RtK4sWb8NcY3d" },
"externalBankAccountId": "Fr8Nq1Lx5Tb3Wz7Km2Vp9d",
"authorizationType": "Recurring",
"evidenceType": "SignedDocument",
"evidenceFileId": "Jv1Qz6Kn3Xw8Lt5Rb2Mp7c",
"authorizedBy": "Dana Ortiz",
"termsVersion": "ach-debit-v1",
"amountRule": { "type": "Maximum", "amount": 5000.00, "currency": "USD" }
}'// 201 Created (trimmed)
{
"id": "Gt5Lw9Xq2Rn6Vb1Zk4Mp8s",
"subject": { "type": "Payer", "id": "Pq7Lm2Xv9RtK4sWb8NcY3d" },
"authorizationType": "Recurring",
"scheme": "Ach",
"ach": { "secCode": "CCD" },
"status": "Active",
"evidenceReviewStatus": "PendingReview"
}What the fields mean:
subjectis what the authorization covers: a whole Payer relationship (Payer) or one
engagement (PayerEngagement).authorizationTypeisOneTime,Recurring(reusable), orStanding. AStandingmandate
needs the payer to act each time, so it can't back automatic collection. A mandate created by an
accepted mandate request is alwaysRecurringwith aMaximumamount rule.amountRule.typeisExact(only that amount),Maximum(anything up to it), orVariable.evidenceTypeisSignedDocumentwhen you captured the authorization, or
InteractiveAcceptancewhen a linked payer accepts in its own session.- Signed-document mandates start with
evidenceReviewStatus: PendingReview. You can use them while
Wingspan reviews the evidence, but debits run under tighter limits until the review is approved.
Mandate status is Draft, Active, Consumed, Revoked, or Expired. Revoke one with
POST /v3/payments/mandates/{mandateId}/revoke.
Revoking also invalidates every default that uses it. Payments already made keep a record of the
authorization they used.
Treat a mandate id as an opaque string. Most are 22-character Wingspan IDs, but some mandates
created through older acceptance flows carry a 32-character hexadecimal ID.
3. Make it the default for the engagement (optional)
To let pay find the bank account and mandate on its own, set them as a pair on the engagement
with PATCH /v3/payments/payers/{payerId}/engagements/{payerEngagementId}:
{
"fundingAuthorization": {
"mode": "Override",
"fundingSource": { "type": "ExternalBankAccount", "id": "Fr8Nq1Lx5Tb3Wz7Km2Vp9d" },
"mandateId": "Gt5Lw9Xq2Rn6Vb1Zk4Mp8s"
}
}For a card you saved through a card setup, use the card's ID and leave
out mandateId:
{
"fundingAuthorization": {
"mode": "Override",
"fundingSource": { "type": "PaymentCard", "id": "Kb7Rt2Nx9Lq4Wz1Vm6Pc3s" }
}
}modeisOverride(use this pair),Inherit(defer to the broader setting), orDisabled
(block collection for this engagement).- A bank account always travels with its mandate, and the mandate must cover the same scope. A card
takes nomandateId. - Invoices created with
payerIduse the Payer's default engagement. Find it with
GET /v3/payments/payers/{payerId}/engagements. - An accepted mandate request or card setup doesn't install this pair for you. Set it yourself once
the mandate isActive(listen forMandate.Activated) or the card setup isActive.
PATCH /v3/payments/payers/{payerId}/settings is not the place to set this. It returns 403 with
detailCode: payments.PayerSettingsRelationshipWriteForbidden, because a payee can't change a
payer's own settings.
4. Collect
Read the invoice first to get its ETag, then call
POST /v3/payments/invoices/{invoiceId}/pay
with If-Match. An empty body uses the engagement's configured pair.
curl -i https://api.wingspan.app/v3/payments/invoices/Hk3nV8qTz1Lw6Rb0Ym5Jcx \
-H "Authorization: Bearer $WINGSPAN_TOKEN"
# ETag: "q3Xv9LmT2RbK7wN4cY8dP1sZ6hF"
curl -X POST https://api.wingspan.app/v3/payments/invoices/Hk3nV8qTz1Lw6Rb0Ym5Jcx/pay \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: northwind-inv-0014-pay" \
-H 'If-Match: "q3Xv9LmT2RbK7wN4cY8dP1sZ6hF"' \
-d '{}'// 200 OK (trimmed)
{
"id": "Hk3nV8qTz1Lw6Rb0Ym5Jcx",
"status": "PaymentInTransit",
"payments": [
{
"id": "Qs4Vn8Lz1Kt6Xw3Rb9Mc2p",
"method": "Ach",
"status": "Pending",
"amount": 3920.00,
"currency": "USD",
"fundingSource": { "type": "ExternalBankAccount" },
"sourceMetadata": { "mask": "0000" }
}
]
}Keep the same Idempotency-Key when you retry a pay call. Wingspan recovers the same payment
instead of starting a second one.
For a one-time debit without saving a default, send the bank account and a one-time mandate in the
body. Both are required together:
{
"method": "Ach",
"fundingSource": { "type": "ExternalBankAccount", "id": "Fr8Nq1Lx5Tb3Wz7Km2Vp9d" },
"mandate": {
"evidenceType": "SignedDocument",
"evidenceFileId": "Jv1Qz6Kn3Xw8Lt5Rb2Mp7c",
"authorizedBy": "Dana Ortiz",
"termsVersion": "ach-debit-v1",
"amountRule": { "type": "Exact", "amount": 3920.00, "currency": "USD" }
}
}The one-time mandate is used for this payment only and can't become a default.
How pay picks the source
pay picks the sourcefundingSource is optional. If you leave it out and a default exists, Wingspan uses the default.
- If the body names a
fundingSource, that wins. A bank account needs amandatewith it. A card
doesn't take one. - Otherwise Wingspan uses the default: the engagement's
fundingAuthorizationpair, following
settingsAuthorityto decide between your setup and the payer's. - At the moment of payment, Wingspan checks the mandate again: it must be
Active, inside its
effective dates, in the right currency, and itsamountRulemust cover the debit.
Rules for the request body:
amountcan be left out. If you send it, it must equal the full invoice amount.methodaccepts onlyAchtoday.shouldEnableAutoPayis for linked payers only. See Automatic collection.
When your client pays from its own Account
A linked payer can pay the invoice itself with its own saved card or bank account. It calls the
same POST /v3/payments/invoices/{invoiceId}/pay in its own Account context:
{ "fundingSource": { "type": "PaymentCard", "id": "Kb7Rt2Nx9Lq4Wz1Vm6Pc3s" } }To pay by bank debit, the payer stores its own bank account and accepts the debit terms in its own
session with POST /v3/payments/mandates and evidenceType: InteractiveAcceptance. No signed
document is needed, and the mandate doesn't go through evidence review. You can't record an
interactive acceptance on the payer's behalf.
The payer also sees the obligation as a payable, where it can approve and schedule it. See
Payables overview.
Ask your client to authorize ACH debits online
A mandate request asks a client who has no Wingspan login to authorize recurring ACH debits from
their business bank account. You create the request. Your client opens a signing page, confirms the
micro-deposits Wingspan sent to their account, and accepts the debit terms. Wingspan then creates a
Recurring mandate with a Maximum amount cap.
Two different credentials are involved:
- You call
/v3/payments/mandate-requestswith your normal Wingspan token (and
X-Wingspan-Accountif you act for a child Account). - The signing page calls
/v3/payments/mandate-sessionswith the request'stokenas its
bearer. It needs no login, noX-Wingspan-Accountheader, and no request ID. The token alone picks
the request.
sequenceDiagram
participant You as You (payee)
participant WS as Wingspan
participant Page as Signing page (payer)
You->>WS: POST /v3/payments/mandate-requests
WS-->>You: 201 MandateRequest with token
WS-->>Page: Email with the link (unless shouldSendEmail is false)
Page->>WS: GET /v3/payments/mandate-sessions (Bearer: token)
Page->>WS: POST .../confirm-deposits with the two amounts
WS-->>Page: 200 with confirmationToken
Page->>WS: POST .../accept with signature, terms, confirmationToken
WS-->>Page: 200 mandate id and status
WS-->>You: Mandate.Activated webhook
You->>WS: PATCH engagement fundingAuthorization with the mandate
1. Create the request (payee)
First store your client's bank account and start micro-deposits, as in
step 1 above. The account must be Pending with verification
attempts left, or already Verified. Then call
POST /v3/payments/mandate-requests:
curl -X POST https://api.wingspan.app/v3/payments/mandate-requests \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"payerId": "Pq7Lm2Xv9RtK4sWb8NcY3d",
"externalBankAccountId": "Fr8Nq1Lx5Tb3Wz7Km2Vp9d",
"payerEmail": "[email protected]",
"amountRule": { "type": "Maximum", "amount": 5000.00, "currency": "USD" }
}'// 201 Created (trimmed)
{
"id": "Wm4Rz8Kq2Nv6Lt1Xb9Pc3d",
"payerId": "Pq7Lm2Xv9RtK4sWb8NcY3d",
"engagementId": "Ej2Vn7Lq5Rt9Kx3Wb6Mc1p",
"externalBankAccountId": "Fr8Nq1Lx5Tb3Wz7Km2Vp9d",
"payerEmail": "[email protected]",
"amountRule": { "type": "Maximum", "amount": 5000.00, "currency": "USD" },
"status": "Pending",
"shouldSendEmail": true,
"depositsConfirmed": false,
"lockedOut": false,
"token": "<43-character secret>"
}amountRuleis alwaysMaximuminUSD: the cap on each debit.engagementIdis optional. Leave it out to use the Payer's default engagement.- By default Wingspan emails the link to
payerEmail. The request and token come back even if the
email fails. Retry delivery with
POST .../{mandateRequestId}/resend. - To host the page yourself, set
"shouldSendEmail": falseand build your own page URL around the
token. The response returns a token, not a URL.
Important:
tokenis a secret that lets anyone holding it act on the request. Share it only
with your client, over a secure channel, and don't log it. While the request isPending, you can
get it back with
GET /v3/payments/mandate-requests/{mandateRequestId}
or by repeating the identical create call. Lists, resends, and cancellations never return it.
Only one pending request can exist for the same engagement and bank account. An identical create
returns the pending request without sending another email. A different one returns 409. To fix a
mistake, cancel the request with
POST .../{mandateRequestId}/cancel
and create a new one. Cancelling revokes the token, unless acceptance has already started creating
the mandate.
List the requests for one engagement with GET /v3/payments/mandate-requests. Both
filter[payerId][eq] and filter[engagementId][eq] are required.
status | Meaning |
|---|---|
Pending | Waiting for your client. depositsConfirmed says whether they've confirmed the micro-deposits. |
Accepted | Your client accepted. mandateId and acceptedAt are set. |
Cancelled | You cancelled it. The token no longer works. |
lockedOut: true means three wrong deposit guesses used up the bank account's verification
attempts. Delete the bank account, add it again, and send a new request.
2. Show the terms and confirm the deposits (payer)
The signing page reads the request with
GET /v3/payments/mandate-sessions:
curl https://api.wingspan.app/v3/payments/mandate-sessions \
-H "Authorization: Bearer $MANDATE_REQUEST_TOKEN"The response has only what the page needs: payeeDisplayName, payeeLogoUrl, bankAccountMask,
amountRule, the terms (termsVersion, termsText, termsSha256), and depositsConfirmed. Show
termsText to your client exactly as returned.
If the deposits aren't confirmed yet, your client enters the two amounts. Send them in dollars to
POST /v3/payments/mandate-sessions/confirm-deposits:
curl -X POST https://api.wingspan.app/v3/payments/mandate-sessions/confirm-deposits \
-H "Authorization: Bearer $MANDATE_REQUEST_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "amount1": 0.32, "amount2": 0.45 }'Each amount is between 0.01 and 0.99, in whole cents. The first successful confirmation returns a
confirmationToken. Keep it in your client's browser session and pass it to the next step. It's
proof that the person accepting the terms is the one who confirmed the deposits.
Important: Keep
confirmationTokenprivate to your client. Don't log it or send it to your
own servers. Reads and repeated confirmations never return it again. Without it, the acceptance
still goes through, but Wingspan staff must review it.
A bank account gets three deposit guesses in its lifetime. When verification isn't available (for
example, after too many wrong guesses), the call returns 422 telling your client to contact the
business that sent the link. The response doesn't say why or how many attempts are left.
3. Accept the terms (payer)
Call POST /v3/payments/mandate-sessions/accept
with the name your client typed as their signature and the termsVersion and termsSha256 from the
read:
curl -X POST https://api.wingspan.app/v3/payments/mandate-sessions/accept \
-H "Authorization: Bearer $MANDATE_REQUEST_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"authorizedBy": "Dana Ortiz",
"termsVersion": "<termsVersion from the read>",
"termsSha256": "<termsSha256 from the read>",
"confirmationToken": "<confirmationToken from confirm-deposits>"
}'// 200 OK
{ "id": "Gt5Lw9Xq2Rn6Vb1Zk4Mp8s", "status": "Active" }id is the new mandate's ID. The bank account must be verified by this point. An invalid
confirmationToken is refused. Wingspan keeps the exact terms text your client saw, and records the
network address and user agent of the caller. If your own server makes this call, those are your
server's details, so call it from your client's browser where you can.
After acceptance, the token only allows acceptance retries and the receipt. A retry returns the
original mandate and its current status. It never changes the recorded signature or reactivates a
revoked, consumed, or expired mandate.
4. Give your client a receipt and a way to revoke (payer)
GET /v3/payments/mandate-sessions/receipt, called with the same token, returns the accepted
terms, authorizedBy, acceptedAt, a short-lived evidenceDownloadUrl for the signed copy, and a
separate revocationToken.
Give your client the revocationToken (for example, in a link on their receipt). With it as the
bearer, they can read the consent at GET /v3/payments/mandate-sessions/revocation and revoke it
without logging in at POST /v3/payments/mandate-sessions/revoke. Revoking stops every later debit
under that mandate.
5. Install the default and collect (payee)
Wait for the Mandate.Activated webhook, or read the request until status is Accepted. Then set
the bank account and mandateId as the engagement's fundingAuthorization, as in
step 3 above. From there, collect with pay
or turn on automatic collection.
Save your client's card
A card setup lets you keep a client's card on file for one engagement, so you can charge it for
invoices without the client logging in. You create the setup. Your client enters the card in a
hosted card frame and accepts the terms. Wingspan verifies the card and, if it passes, the setup
becomes Active.
As with mandate requests, you use your normal Wingspan token on /v3/payments/payer-card-setups,
and the card page uses the setup's token as its bearer on /v3/payments/payer-card-sessions.
1. Create the setup and send it (payee)
Call POST /v3/payments/payer-card-setups. Idempotency-Key is required, and engagementId is
required here.
curl -X POST https://api.wingspan.app/v3/payments/payer-card-setups \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"payerId": "Pq7Lm2Xv9RtK4sWb8NcY3d",
"engagementId": "Ej2Vn7Lq5Rt9Kx3Wb6Mc1p",
"payerEmail": "[email protected]",
"maximumAmount": 2500.00
}'// 201 Created (trimmed)
{
"id": "Kb7Rt2Nx9Lq4Wz1Vm6Pc3s",
"payerId": "Pq7Lm2Xv9RtK4sWb8NcY3d",
"engagementId": "Ej2Vn7Lq5Rt9Kx3Wb6Mc1p",
"payerEmail": "[email protected]",
"maximumAmount": 2500.00,
"status": "Pending",
"token": "<43-character secret>"
}maximumAmountis the authorization cap in USD, shown to your client in the terms.- The setup's
idis also the ID of thePaymentCardit creates. Use it as the funding source later. - Creating a setup doesn't email anyone. Send the link to
payerEmailwith
POST /v3/payments/payer-card-setups/{paymentCardId}/send, which also works as a resend while the
setup isPending. Or deliver thetokenyourself on your own page. Sending needs a legal name on
file for your Account, or it returns422. tokenis a secret, like the mandate request token.GET /v3/payments/payer-card-setups/{paymentCardId}
returns it again while the setup isPending.
List setups for one engagement with GET /v3/payments/payer-card-setups, filtered by both
filter[payerId][eq] and filter[engagementId][eq].
status | Meaning |
|---|---|
Pending | Waiting for your client to enter the card and accept. |
Processing | Your client accepted. Wingspan is verifying the card. |
Active | The card passed verification and can be charged. |
Failed | The card issuer declined verification. Create a new setup. |
Revoked | Your client revoked consent, or the card was removed. |
2. Capture the card (payer)
The card page calls POST /v3/payments/payer-card-sessions/capture with the setup token as bearer
and a required Idempotency-Key. The response has provider (Footprint), a short-lived
clientToken, and its expiresAt. Use clientToken to open the hosted card frame. Card numbers go
straight to the hosted frame and never through the Wingspan API. Call capture again to refresh an
expired clientToken.
Wingspan requires trusted risk context from your client's browser on capture and accept. If you
build your own card page rather than using the emailed link, contact support to set this up.
3. Show the terms and accept (payer)
After the card is entered, GET /v3/payments/payer-card-sessions returns payeeDisplayName,
cardBrand, cardLastFour, and the terms (termsVersion, termsText, termsSha256). Show the
terms exactly as returned. When your client agrees, call
POST /v3/payments/payer-card-sessions/accept:
curl -X POST https://api.wingspan.app/v3/payments/payer-card-sessions/accept \
-H "Authorization: Bearer $CARD_SETUP_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"authorizedBy": "Dana Ortiz",
"termsVersion": "<termsVersion from the read>",
"termsSha256": "<termsSha256 from the read>"
}'The response is the session with status (Processing, then Active or Failed), acceptedAt,
a revocationToken, and an evidenceDownloadUrl for the signed copy once it's stored. Wingspan
records the caller's network address and user agent with the acceptance.
With the revocationToken as bearer, your client can read the consent at
GET /v3/payments/payer-card-sessions/revocation and revoke it without logging in at
POST /v3/payments/payer-card-sessions/revoke. Revoking removes the card, so it can't be charged
again.
4. Install the default and collect (payee)
Poll GET /v3/payments/payer-card-setups/{paymentCardId} until status is Active. PaymentCard
webhook events aren't available to subscribe to yet. Then set the card as the engagement's
fundingAuthorization, with no mandateId, as in
step 3 above. You can also name it directly
on pay:
{ "fundingSource": { "type": "PaymentCard", "id": "Kb7Rt2Nx9Lq4Wz1Vm6Pc3s" } }Pay by payment link
A payment link lets a client with no Wingspan login pay one invoice by card or US bank debit. You
create the link, and the payer's browser uses it to run a short hosted payment flow.
Create the link (payee)
Call POST /v3/payments/invoices/{invoiceId}/payment-links.
Idempotency-Key is required.
curl -X POST https://api.wingspan.app/v3/payments/invoices/Hk3nV8qTz1Lw6Rb0Ym5Jcx/payment-links \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Idempotency-Key: northwind-inv-0014-link"// 201 Created
{
"id": "Lq3Zx8Vn1Rt6Kb4Wm9Pc2d",
"invoiceId": "Hk3nV8qTz1Lw6Rb0Ym5Jcx",
"status": "Active",
"createdAt": "2026-09-24T15:20:03Z",
"secret": "<43-character secret>",
"metadata": {}
}Important:
secretis returned only here. Wingspan stores a hash, not the secret. If you lose
the response, repeat the request with the sameIdempotency-Keyto get the same result back.
An invoice has at most one active link. GET .../payment-links shows it without the secret.
DELETE .../payment-links/{paymentLinkId} revokes it and cancels any payment in progress that it
authorized.
Pay through the link (payer)
The payer's side authenticates with the link secret as a bearer token, not with a Wingspan login.
Each step returns an ETag, and every step after the first needs If-Match and an
Idempotency-Key.
sequenceDiagram
participant Page as Payer's browser
participant WS as Wingspan
participant Host as Hosted capture
Page->>WS: POST /v3/payments/invoice-payment-intents (Bearer: link secret)
WS-->>Page: 201 intent, capabilityToken, nextAction
Page->>Host: Card frame or bank link from nextAction
Page->>WS: POST .../{id}/payment-method (Bearer: capabilityToken)
WS-->>Page: 200 with authorization terms and digest
Page->>WS: POST .../{id}/confirm with authorizationDigest, accepted: true
WS-->>Page: 200 Processing
Page->>WS: GET .../{id} until Succeeded or Failed
- Create an intent with
POST /v3/payments/invoice-payment-intents
and{ "paymentMethodType": "PaymentCard" }or{ "paymentMethodType": "ExternalBankAccount" }.
The response has acapabilityTokenfor the rest of the flow, aquote(invoiceAmount,
payerFeeAmount,totalDebitAmount), and anextAction. If the invoice doesn't accept that
method, you get409 InvalidStateTransition. Try the other method. - Capture the method.
nextAction.typeisLaunchPaymentCardFrame(a hosted card frame) or
LaunchExternalBankAccountLink(a hosted bank link). After the payer finishes, call
POST .../{invoicePaymentIntentId}/payment-method
with{}for a card, or{ "accountHolderType": "Business", "accountHolderName": "Northwind Staffing LLC" }
for a bank account. - Show the terms and confirm. The response carries
paymentCardAuthorizationor
bankDebitAuthorization, including the exact amounts and anauthorizationDigest. Show those
terms to the payer. When they agree, call
POST .../{invoicePaymentIntentId}/confirm
with{ "paymentMethodType": "PaymentCard", "authorizationDigest": "<digest>", "accepted": true }. - Check the outcome. Poll
GET .../{invoicePaymentIntentId}.
Intent status values:
| Status | Meaning |
|---|---|
RequiresPaymentMethod | Waiting for the payer to enter a card or bank account. |
RequiresConfirmation | Waiting for the payer to accept the terms. |
Processing | The payment is being processed. |
Succeeded | The payment completed. |
Failed | The payment failed. outcome.reasonClass says why. |
PartiallyRefunded, Refunded | Money was refunded to the original method. |
Returned, RecoveryRequired | A bank debit was returned after it completed. |
Expired | The intent timed out before the payer finished. |
A stale If-Match returns 412 with the current ETag. Read the intent again and retry.
To show the invoice on your payment page before the payer pays, read the limited view with
GET /v3/payments/payer-invoice-view/{opaqueToken}, using the invoice's payerInvoiceViewToken.
That token only grants viewing and stays valid after the invoice is paid or a payment link is
revoked.
Bank transfer (wire or ACH credit)
If the invoice accepts Manual, your client can push money by wire or ACH credit. Get the
instructions to share with them:
curl https://api.wingspan.app/v3/payments/payers/Pq7Lm2Xv9RtK4sWb8NcY3d/bank-transfer-processing-account \
-H "Authorization: Bearer $WINGSPAN_TOKEN"The response has routingNumber, accountNumber, and bankName. It contains full account details,
so it's never cached. Don't log it.
Record a payment made outside Wingspan
If your client paid by check, cash, or any channel outside Wingspan, record it with
POST /v3/payments/invoices/{invoiceId}/pay-off-platform.
The invoice moves to PaidOffPlatform.
curl -X POST https://api.wingspan.app/v3/payments/invoices/Hk3nV8qTz1Lw6Rb0Ym5Jcx/pay-off-platform \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": 3920.00,
"paymentMethodDescription": "Check",
"paidDate": "2026-10-20",
"referenceNumber": "CHK-20931"
}'amount, paymentMethodDescription, and paidDate are required, and the call records a full
payment. Repeating it with the same details is safe. A different set of details returns 409.
Off-platform payments can't be refunded through Wingspan.
Automatic collection
You can have Wingspan collect your client's opened invoices without a pay call for each one.
Turn it on for a Payer (payee)
Set isAutomaticInvoiceCollectionEnabled on the Payer with
PATCH /v3/payments/payers/{payerId}:
curl -X PATCH https://api.wingspan.app/v3/payments/payers/Pq7Lm2Xv9RtK4sWb8NcY3d \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "isAutomaticInvoiceCollectionEnabled": true }'The setting applies to every engagement with that Payer, but Wingspan only collects on an engagement
that has its own installed fundingAuthorization and an active recurring authorization. For bank
debit that means an Active Recurring mandate, such as the one an accepted
mandate request creates. Collection still runs
the same funding and mandate checks as pay.
Linking the Payer to a Wingspan Account doesn't turn this off. It keeps working while the
engagement's funding resolves to the bank account and mandate you set up. If settingsAuthority
makes the payer's own setup win, your setting doesn't carry over to the payer's bank account.
Turning the setting off, removing the funding pair, or revoking the mandate stops new collections.
Payments already under way keep the funding they started with.
Override it for one engagement or series
automaticCollectionMode (Inherit, Enabled, or Disabled) sets the same preference at a
narrower scope:
- On an engagement, with
PATCH /v3/payments/payers/{payerId}/engagements/{payerEngagementId}. - On a recurring invoice, when you create or update it. See Recurring invoices.
The most specific explicit setting wins: the recurring invoice, then the engagement, then the Payer.
Inherit clears the setting at that scope, and leaving the field out of a PATCH keeps it as is.
Changes apply to payments started after the change.
When a linked payer turns on auto-pay
A linked payer can turn on auto-pay while paying. Sending "shouldEnableAutoPay": true on pay
saves the payer's selected method and records its consent to later collections you start. It needs a
saved method (not a one-time one) and a Recurring mandate for bank debit. An unlinked payer can't
turn it on this way, and neither can you. Use isAutomaticInvoiceCollectionEnabled for unlinked
payers.
Track the payment
Each collection attempt appears in the invoice's payments[] with its own status: Pending,
Processing, Completed, Failed, Cancelled, or Returned. A failed or returned attempt carries
a statusReason, such as InsufficientFunds or AccountNotFound.
Subscribe to these published webhooks:
InvoicePayment.Initiated,.Processing,.Completed,.Failed,.Returned,.Cancelled
for each attempt.Invoice.PaymentInTransit,Invoice.Paid,Invoice.DepositConfirmed,Invoice.PaymentFailed,
andInvoice.Returnedfor the invoice.Mandate.*for authorization changes, such asMandate.Revoked.
Invoice.Paid is Wingspan's internal state. Invoice.DepositConfirmed means Wingspan's originating
bank or provider reported its terminal processed state. It doesn't mean the recipient's bank received
the money, that funds are available, or that the payment can't be returned. A bank debit can still
come back as Invoice.Returned after DepositConfirmed. See
Paid vs DepositConfirmed.
If a debit is returned because the payer didn't authorize it, Wingspan revokes the mandate and turns
off the defaults that use it. Get a new authorization before you debit that account again.
What can go wrong
| Response | Cause | Fix |
|---|---|---|
403 StepUpMfaRequired | pay and createMandate need elevated authentication. | Complete the step-up challenge and retry. |
403 on pay | Collection isn't turned on for your Account. | Contact support. |
409 with detailCode: payments.FundingSourceRequired | No usable payment method was found in the body or the engagement's setup. | Send a fundingSource, or set a fundingAuthorization pair. |
409 with detailCode: payments.MandateRequired | The bank account has no matching mandate at the same scope. | Create a mandate for that scope, or send a one-time mandate. |
409 on a split invoice | Collecting an invoice that carries collaborator splits through pay isn't supported yet. | Contact support. |
412 PreconditionFailed | The invoice changed since you read its ETag. | Read it again and retry with the new ETag. |
422 ValidationError | For example, amount isn't the full invoice amount or method isn't Ach. | Fix the body. errors[] names the field. |
401 on a mandate or card session call | The bearer token is wrong, was cancelled, or doesn't allow that step (for example, reading the signing page after acceptance). | Use the right token for the step. For a cancelled request, send a new one. |
409 on createMandateRequest | A different pending request already exists for the same engagement and bank account. | Cancel the pending request, then create the corrected one. |
422 on confirm-deposits | The amounts are wrong, or the bank account can't be verified any more. | Your client contacts you. If lockedOut is true, delete and re-add the bank account and send a new request. |
Branch on code. detailCode helps you debug. See Errors and
Concurrency and ETags.
Related pages
- Invoice lifecycle
- Refunds and returns
- Payment and payout methods
- Payout methods, for where your proceeds land
Updated 10 days ago