Sync Wingspan to Campfire

Post Wingspan invoices, payments, refunds, credits, and contractor payouts to a Campfire ledger. A pattern guide covering object mapping, the event-driven sync, how to make every Campfire write safe to retry, and the Campfire API details that trip up a first build.

You write the middleware that reads activity from Wingspan and writes it to Campfire. The sync only reads from Wingspan. It records in Campfire what already happened in Wingspan, and it never moves money.

Where Wingspan fits

  • Wingspan invoices your clients, collects card and ACH payments, records checks and wires paid outside Wingspan, pays contractors in payroll runs, and files 1099s.
  • Campfire is the general ledger: AR and AP, revenue recognition, consolidation, and close.
  • Campfire's bank feed posts a journal entry for every transaction on your connected bank accounts.

Data flows one way, from Wingspan to Campfire. Campfire stays the book of record, and Wingspan stays the record of every payment.

How the sync works

Wingspan pushes invoice, invoice payment, and payable events to your webhook as they happen. Every 15 minutes your sync also reads the 30-day event log from a saved cursor, which catches anything a push missed. Invoice opens, off-platform payments, refunds, and cancellations reach your sync through a sweep of the invoice list by status, on the same schedule.

Treat every event as a pointer. Read the invoice or payable from Wingspan, convert it, and write to Campfire only what Campfire doesn't already have. Each pass converges Campfire to Wingspan's current state, so duplicate, late, and out-of-order deliveries all end in the same place.

Keep a sync log that maps each Wingspan ID to the Campfire ID it became. It's your first stop before any Campfire write, and it's what a person reads when something fails.

sequenceDiagram
  participant WS as Wingspan
  participant Sync as Your sync
  participant CF as Campfire
  WS->>Sync: Webhook (Invoice.Paid)
  Sync->>WS: GET /v3/payments/invoices/{id}?expand=payer
  Sync->>Sync: Sync log: what's already in Campfire?
  Sync->>CF: Look up by our own ID
  Sync->>CF: Create or update only what's missing
  Sync->>Sync: Record the Campfire IDs
  Note over Sync,WS: Every 15 minutes: event log from the cursor, then a status sweep

Set up Campfire first

Do this with your controller before the first sync.

  • API access. Create a Campfire API user with the Admin role for the sync alone, and send its key as Authorization: Token <key>. Campfire allows five requests per second per API user, shared by everything that uses the key.
  • Accounts, per entity:
    • A Wingspan clearing account, where card and ACH collections wait until the deposit reaches your bank.
    • A Wingspan payout funding account, the balance you fund in Wingspan for payroll.
    • A refunds and credits account (contra-revenue).
    • A contractor cost account. In net revenue mode this is a pass-through account, and you also need a revenue account for your spread.
  • Products. Campfire requires a product on every invoice line, and the product decides the revenue account. Create one for each kind of line you invoice, such as each revenue stream and each fee.
  • Tags. Create the tag groups and tags you report by, such as skillset and region, before the first sync. The sync only assigns tags that already exist.
  • Bank rules. Add a Campfire bank rule that categorizes Wingspan deposits to the clearing account, and one that categorizes Wingspan funding pulls to the funding account.
  • A cutover date. Invoices opened and payouts paid before it stay in your previous system.

Map your objects

WingspanCampfireNotes
PayerCustomer (vendor_type: customer)external_id = the payer's externalId, so customers you already have in Campfire are reused.
Invoice, once openedInvoicesource: wingspan, source_id and integration_id = the Wingspan invoice ID, ref_number = the invoice's externalId (your own number).
Invoice line itemInvoice lineproduct by rule, amount = the gross line amount, discount = the line's discount amount, tag_ids, service_date.
Invoice payment by card or ACH, CompletedInvoice payment into the Wingspan clearing accountexternal_id = the Wingspan payment ID.
Off-platform payment (check or wire)Invoice payment into the operating bank accountDated with the date entered in Wingspan.
Returned or failed paymentVoid the invoice paymentThe invoice reopens in Campfire.
RefundJournal entry: debit refunds and credits, credit clearingCampfire has no refund object on invoices.
Credit balance applied to an invoiceCredit memo, applied to the invoiceintegration_id = the Wingspan credit ID.
Cancelled invoiceVoid the invoiceA cancelled invoice that never reached Campfire is skipped.
PayeeVendor (vendor_type: vendor)Bill mode only.
Payables paid in a payroll runOne journal entry per run, or one bill per payableYour controller picks. See Payouts.
Returned payableReversing entry, or void the bill payment and leave the bill openDated the day the payout came back. Void the bill only if Wingspan cancels the payable.

Receivables

1. Find or create the customer

Call POST /coa/api/vendor/bulk-search with upsert: true and your external_id. The response takes two shapes. When every row matches, it's a plain array of customers. When a row's external_id matches nothing, it's {vendors, errors}, still with a 200. Bulk-search only creates rows that have no external_id, so a row that has one comes back in errors instead.

Create each row reported in errors with POST /coa/api/vendor, using the error's index to find the row you sent. Then check vendor_type on every match. Customers and vendors share one external_id namespace, so a contractor's ID can collide with a client's. When a match is the wrong kind, stop and flag it so you never invoice a vendor.

2. Create the invoice

Look first, then create. Search GET /coa/api/v1/invoice/?entity=<id>&invoice_number=<number> (an exact match) and take the result whose source_id is the Wingspan invoice ID. Create the invoice only when none matches:

POST /coa/api/v1/invoice/
{
  "entity": 101,
  "client": 1000,
  "invoice_number": "WS-1001",
  "ref_number": "INV-1001",
  "invoice_date": "2026-10-07",
  "due_date": "2026-11-06",
  "currency": "USD",
  "source": "wingspan",
  "source_id": "Dq8kXF2uGZ1J1MPOBhOUkk",
  "integration_id": "Dq8kXF2uGZ1J1MPOBhOUkk",
  "lines": [
    {
      "product": 301,
      "description": "Shift S-101, Priya Shah",
      "quantity": 8,
      "rate": 150,
      "amount": 1200,
      "discount": 300,
      "service_date": "2026-10-05",
      "tag_ids": [701, 801]
    },
    {
      "product": 305,
      "description": "Platform fee | Shift S-101",
      "quantity": 1,
      "rate": 45,
      "amount": 45,
      "discount": 0,
      "tag_ids": []
    }
  ]
}

Compare Campfire's total_amount with the Wingspan invoice total. If they differ, flag the invoice and hold its payments until someone fixes the mapping.

3. Record payments

For each completed payment, check GET /coa/api/v1/invoice-payments?external_id=<payment id>&voided=false. If there's no match, record it:

POST /coa/api/v1/invoice/{invoice_id}/pay/
{
  "empty_transactions": [
    {
      "account_id": 1101,
      "account_name": "Wingspan Clearing (US)",
      "amount": 945,
      "payment_date": "2026-10-08",
      "external_id": "0yXMCmmdGOarlfDfdzghmk",
      "memo": "Wingspan payment 0yXMCmmdGOarlfDfdzghmk on invoice WS-1001",
      "currency": "USD"
    }
  ]
}

The response lists the new payment_ids. Card and ACH payments go to the clearing account. Off-platform payments go to the operating bank account.

To get a payment's booking date, convert its completedAt timestamp to the entity's time zone. Off-platform payments are different. Wingspan stores the date someone entered as midnight UTC, so 2026-10-08 comes back as 2026-10-08T00:00:00Z. Take the date part as is. Converting it to a US time zone books the check a day early.

4. Returned and failed payments

When a payment comes back after you recorded it, void the Campfire payment with POST /coa/api/v1/invoice/{invoice_id}/payment/{payment_id}/void/ and a void_date. Look the payment up by external_id first, so you don't send a void that already went through.

5. Refunds

Post a refund as a journal entry against the invoice. Use the *_native amount fields, which are in the transaction's currency, and set type: journal_entry:

POST /coa/api/journal_entry
{
  "type": "journal_entry",
  "date": "2026-10-07",
  "entity": 101,
  "currency": "USD",
  "memo": "Wingspan refund rf_1 on invoice WS-1001",
  "source": "wingspan",
  "source_id": "refund:rf_1",
  "transactions": [
    { "account": 4900, "debit_amount_native": 256, "credit_amount_native": 0, "tags": [{ "id": 701 }] },
    { "account": 1101, "debit_amount_native": 0, "credit_amount_native": 256 }
  ]
}

The bare debit_amount and credit_amount fields are in the consolidation currency. A Canadian entity that sends those posts CAD amounts as if they were USD.

6. Credits

A credit on an open invoice is a discount on the line in Wingspan. It reaches Campfire as that line's discount, so the gross amount and the credit both stay on the invoice.

A credit balance applied to an invoice becomes a Campfire credit memo with integration_id set to the Wingspan credit ID. Apply it with POST /coa/api/v1/invoice/{invoice_id}/pay/ and credit_memos: [{credit_memo_id, amount, posted_at}].

7. Cancellations and changes

  • Cancelled. Void the Campfire invoice with POST /coa/api/v1/invoice/{invoice_id}/void/. Check its voided_date first.
  • Edited before payment. Patch the lines and due date with PATCH /coa/api/v1/invoice/{id}/. Decide whether to patch by comparing what Wingspan sent this time with what it sent last time. If you compare mapped Campfire payloads instead, a mapping change on your side rewrites invoices already in Campfire, and some of those may sit in a closed period.
  • Edited after a payment synced. Leave the Campfire invoice alone. Wingspan locks lines once an invoice is paid.

Payouts

Your controller picks one of two ways to book contractor payouts.

One journal entry per payroll run

This suits large contractor counts. When every payable in a payroll run is paid, post one entry: debits to contractor cost, summed by tag combination, and one credit to the Wingspan funding account. Use the run as the source_id, such as run:<payrollRunId>.

Record which payables the entry covered as soon as Campfire returns its ID. If your sync crashes after the entry exists, it must never post those payables again, and a later return needs to know which entry to reverse.

One bill per payable

This gives you a balance per contractor in Campfire.

  1. Find or create the vendor, as in step 1, with vendor_type: vendor.
  2. Create the bill with bill_type: PAYROLL, source_id = the payable ID, and one line per payable line to contractor cost. Tag each line as tags: [{"id": 701}], the form Campfire's accounts payable guide uses.
  3. Read the bill with GET /coa/api/v1/bill/{id}/ and pay what's still due (amount_due) from the Wingspan funding account with POST /coa/api/v1/bill/{bill_id}/pay/. Someone in Campfire may already have paid part of it.
  4. Store the ID of the payment you added. The bill pay endpoint answers with the whole bill, where the invoice pay endpoint returned payment_ids. Your payment is the active entry in payments that wasn't there before you paid. A voided payment stays on the bill with voided_date set.

Returns

A payable can come back after it was paid. The return date is on the payout leg (payouts[].events.returnedAt), and the payable itself doesn't carry it. See Refunds and returns.

  • Journal mode. Post a reversing entry for that payable's lines, dated the return. If the payable never made it into a posted run, drop it from the run instead.
  • Bill mode. Void the payment you recorded with POST /coa/api/v1/bill/{bill_id}/payment/{payment_id}/void/ and leave the bill open. If Wingspan pays the payable again, the sync pays the same bill. If Wingspan cancels the payable instead, void the bill with POST /coa/api/v1/bill/{bill_id}/void/, which reverses the contractor cost. If the bill also carries a payment someone added in Campfire, void only yours and flag the bill for a person.

A return reverses the cost, which understates expense while the contractor is still owed the money. If your controller prefers, credit a liability account for amounts due to contractors instead of contractor cost. When Wingspan pays that payable again, debit the liability instead of cost in that run's entry.

Net revenue (optional)

If you book only your spread as revenue, invoice lines post to the pass-through account. Once a job's payout is known, post a daily reclass entry per entity that moves the spread from pass-through to revenue. Freeze each batch of jobs before you post it, so a retry re-posts exactly the same set under the same source_id.

Never post the bank side

Campfire's bank feed already posts a journal entry for every bank transaction, so a deposit or funding entry from your sync would count the cash twice. Collections land in the clearing account, and your bank rule moves each deposit out of it. Payout runs are the one cash entry the sync posts, because your Wingspan funding balance has no bank feed.

Check one Campfire setting. With skip journal on bank sync turned on, the bank feed records transactions without journal entries, and your bank rules have to post those entries instead.

Make every Campfire write safe to retry

Campfire has no idempotency key on invoices, bills, or journal entries. A create that timed out may still have been saved, so don't resend it. Record the failure, and on the next pass look the object up by your own ID before you create it.

ObjectFind it withMatch on
Customer or vendorPOST /coa/api/vendor/bulk-searchexternal_id
InvoiceGET /coa/api/v1/invoice/?entity&invoice_numbersource_id
Invoice paymentGET /coa/api/v1/invoice-payments?external_id&voided=falseexternal_id
Credit memoGET /coa/api/v1/credit-memo?entity&client, every pageintegration_id
Credit applied to an invoiceGET /coa/api/v1/invoice-payments?invoice_id&voided=falsepayment_type: CREDIT_MEMO, amount, and date
Journal entryGET /coa/api/journal_entry?source=wingspan&start_date&end_date, a day either side of the entry datesource_id
BillGET /coa/api/v1/bill/?entity&vendor, every page, then GET /coa/api/v1/bill/{id}/source_id

Two of these filters don't do what their names suggest. On credit memos, start_date and end_date filter on when the memo was created and ignore its credit_memo_date. Search without dates, or a memo dated another day goes unfound and posts twice. On bills, the same two fields filter on bill_date, which a person can edit in Campfire. Search the vendor's bills without dates, and confirm a candidate by reading the bill itself.

Space calls evenly at four per second and leave the fifth for other tools on the key. On a 429, wait out Retry-After and pause every caller on the key, since they all share the limit. Retry reads freely. Retry a write only after a lookup.

Subscribe to Wingspan events

Create one subscription for the Account that owns your entities, including its child Accounts. See Create a webhook subscription.

POST /v3/platform/webhooks
Idempotency-Key: <uuid>
{
  "url": "https://sync.example.com/webhooks/wingspan",
  "subscribedEvents": ["Invoice.*", "InvoicePayment.*", "Payable.Paid", "Payable.PartiallyPaid", "Payable.Returned"],
  "scope": { "type": "Account", "id": "<accountId>", "shouldIncludeDescendants": true },
  "description": "Campfire sync"
}
  • Store the secret from the response. Wingspan returns it once.
  • Check the Wingspan-Signature header on every push, and skip any event whose ID you've already processed. See Verify webhook signatures.
  • Answer with a 2xx quickly and do the Campfire work after. Save the event before you act on it, so a crash leaves it pending for the next poll.
  • Read the event log every 15 minutes from a saved cursor. At go-live, start from the head of the log. After an outage, resume from your saved cursor. If the cursor is lost or older than the 30-day window, replay from the beginning of the log and let the status sweep cover anything older. See Recover missed events.
  • Sweep the invoice list by status (Opened, Overdue, PaidOffPlatform, Refunded, PartiallyRefunded, Cancelled) on the same schedule, and skip any invoice whose events.updatedAt hasn't changed since the last sync.

Read each resource with its own Account in X-Wingspan-Account. See Acting on behalf of Accounts.

Entities and currencies

  • Map one Wingspan Account to one Campfire entity. A US entity and a Canadian entity each get their own clearing, funding, and cost accounts.
  • Post in the transaction's currency with the *_native amount fields. Campfire computes the book and consolidation amounts.
  • Book dates in the entity's time zone, except off-platform payment dates, which are already calendar dates.

Pitfalls

  1. Bulk-search doesn't create rows that have an external_id. A row with an ID that matches nothing comes back in errors. Create it with POST /coa/api/vendor.
  2. Customers and vendors share one external_id namespace. Check vendor_type on every match.
  3. The bill pay response isn't the invoice pay response. Invoice pay returns payment_ids. Bill pay returns the bill.
  4. Credit memo date filters use the creation date. Look memos up by customer and integration_id.
  5. Journal lines need *_native amounts and type: journal_entry. The bare amount fields are consolidation currency.
  6. Every invoice line needs a product, and tags must exist first. A missing product or tag fails the whole invoice.
  7. Each object takes tags its own way. Invoice lines take tag_ids: [701]. Journal entry lines take tags: [{"id": 701}]. Bill lines take tags: [{"id": 701}] too, as the accounts payable guide shows, even though Campfire's schema marks that field read-only.
  8. Off-platform payment dates are midnight UTC. Use the date part.
  9. Match your line rules without regard to case. A line tagged PlatformFee and a rule for platformFee should still match, or fee revenue books to your default product.
  10. Never post the bank side. The bank feed already does.
  11. A mapping change shouldn't rewrite history. Compare Wingspan's data to decide whether to patch an invoice, and leave paid invoices alone.
  12. Five requests per second, shared by every tool on the key. Space calls evenly instead of sending bursts.

Before you go live

  1. Agree the mapping with your controller: entities, accounts, products, tags, revenue mode (gross or net), payout mode (journal or bill), and the cutover date.
  2. Create the Campfire accounts, products, tags, and bank rules.
  3. Run one pay cycle end to end in a Campfire sandbox workspace or a test entity, and compare totals.
  4. Turn on the sync with the event log starting at its head, then backfill invoices that were open at cutover.
  5. Run alongside your current process for one pay cycle before you switch.

Related pages


Did this page help you?