Sync Wingspan to Rillet
Post Wingspan invoices, payments, refunds, credits, and contractor payouts to Rillet. A pattern guide covering object mapping, the event-driven sync, how to make every Rillet write safe to retry, and the Rillet API details that trip up a first build.
You write the middleware that reads activity from Wingspan and writes it to Rillet. The sync only reads from Wingspan. It records in Rillet 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.
- Rillet is the general ledger: AR and AP, revenue recognition, consolidation across subsidiaries, and close.
- Rillet's bank feed imports the transactions on your connected bank accounts, and your team reconciles them against the payments in Rillet.
Data flows one way, from Wingspan to Rillet. Rillet stays the book of record, and Wingspan stays the record of every payment. Wingspan already sends each invoice, so Rillet records it and doesn't send it again.
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 Rillet only what Rillet doesn't already have. Each pass converges Rillet 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 Rillet ID it became. It's your first stop before any Rillet write, and it's what a person reads when something fails. Several Rillet objects have no field for a Wingspan ID, so for those the sync log is the main record of what you posted. Write the sync log row in the same transaction that marks the event processed.
Run all work for one Wingspan object on one worker at a time, with a lock keyed on the Wingspan ID. A webhook and the status sweep can arrive for the same invoice at once, and without the lock both pass the lookup and both write.
sequenceDiagram
participant WS as Wingspan
participant Sync as Your sync
participant R as Rillet
WS->>Sync: Webhook (Invoice.Paid)
Sync->>Sync: Lock the invoice ID
Sync->>WS: GET /v3/payments/invoices/{id}?expand=payer
Sync->>Sync: Sync log: what's already in Rillet?
Sync->>R: On a sync log miss, look up by the wingspan external reference
Sync->>R: Create (with an Idempotency-Key) only what's missing
Sync->>Sync: Record the Rillet IDs and mark the event processed
Note over Sync,WS: Every 15 minutes: event log from the cursor, then a status sweep
Set up Rillet first
Do this with your controller before the first sync.
-
API access. Ask Rillet to turn on API access for your organization, then create a key under Settings, API access. Send it as
Authorization: Bearer <key>. Build and test againsthttps://sandbox.api.rillet.combefore you point the sync athttps://api.rillet.com. -
API version. Send
X-Rillet-API-Versionon every request, set to the version you tested against. Rillet's published OpenAPI spec is version 4.0 as of October 2026. A request without the header gets Rillet's latest version, which can change under a running sync. -
An external reference type. Create one for Wingspan IDs. Rillet accepts only reference types that already exist, and it returns the existing type with a
200when you send the sameslugagain, so this call is safe to repeat:POST /external-reference-types { "slug": "wingspan", "name": "Wingspan" } -
Accounts. Rillet's chart of accounts belongs to the organization. Create these accounts in it:
- A Wingspan clearing account for each subsidiary, where card and ACH collections wait until the deposit reaches your bank.
- A Wingspan payout funding account for each subsidiary, 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.
Every payment you record names an
account_code, and Rillet resolves it to a cash account. Create the clearing and funding accounts withtype: ASSETandsubtype: Bank. Then register each of them as a bank account in its subsidiary withPOST /bank-accounts(name,currency,bank_name,subsidiary_id,account_code), with no feed connected. Post a test payment to each one in the sandbox to confirm it works. The refunds and cost accounts are ordinary ledger accounts. -
Products. Every invoice line and credit memo line names a
product_id, and the product sets the default revenue account and recognition pattern. Create one for each kind of line you invoice, such as each revenue stream and each fee, and one for credit balances, which posts to the refunds and credits account. -
Fields. Rillet's custom fields are your reporting dimensions, such as skillset and region. Create each field and its values before the first sync. The sync only assigns values that already exist.
-
Customer settings. Customers the sync creates should have
send_invoices_automaticallyandsend_payment_remindersset tofalse. Wingspan already sends the invoice and the reminders. -
Approval workflows. A bill approval workflow can be scoped by submission source (
submission_sources), so excludePUBLIC_APIfrom any bill workflow and the sync's bills post without approval. Journal entry workflows have no source scope. An active one holds every entry the sync posts, and the journal entry response doesn't say so. Before go-live, have your controller confirm no journal entry workflow matches the sync's entries, or agree that someone approves them each run and reconciles unapproved entries at close. -
A cutover date. Invoices opened and payouts paid before it stay in your previous system.
Map your objects
| Wingspan | Rillet | Notes |
|---|---|---|
| Payer | Customer | external_references = {type: "wingspan", id: <payer ID>}. |
| Invoice, once opened | Invoice, scope: REVENUE_RECOGNITION_ONLY | invoice_number = the Wingspan invoice number. An external reference on the invoice and on every line. |
| Invoice line item | Invoice item | product_id by rule, total_amount = the line amount after discount, fields for dimensions. |
Invoice payment by card or ACH, Completed | Invoice payment to the Wingspan clearing account | Store the Rillet payment ID in your sync log. Payments carry no external reference. |
| Off-platform payment (check or wire) | Invoice payment to the operating bank account | Dated with the date entered in Wingspan. |
| Returned or failed payment | Delete the invoice payment | Only while the payment is uncleared and its period is open. |
| Refund | Journal entry: debit refunds and credits, credit clearing | related_entity = the customer. Rillet has no refund endpoint. |
| Credit balance applied to an invoice | Credit memo, applied to the invoice | External reference = the Wingspan credit ID. |
| Cancelled invoice | Credit memo that mirrors the invoice lines, applied to it | Rillet has no void. A cancelled invoice that never reached Rillet is skipped. |
| Payee | Vendor | Bill mode only. |
| Payables paid in a payroll run | One journal entry per run, or one bill per payable | Your controller picks. See Payouts. |
| Returned payable | Reversing entry, or delete the bill payment and leave the bill open | Dated the day the payout came back. A vendor credit only if Wingspan cancels the payable. |
Money in Rillet is an object with the amount as a decimal string, such as {"amount": "1200.00", "currency": "USD"}, rounded to the currency's decimal places. Convert Wingspan amounts to that form on every write.
Receivables
1. Find or create the customer
Search with GET /customers?external_reference_type=wingspan&external_reference_id=<payer ID>. If nothing matches, create the customer:
POST /customers
Idempotency-Key: <key>
{
"name": "Northwind Health",
"emails": [{ "email": "[email protected]", "type": "MAIN_SENDER" }],
"payment_terms": 30,
"send_invoices_automatically": false,
"send_payment_reminders": false,
"external_references": [{ "type": "wingspan", "id": "Hk2mPq7rT0aY4wLzXcVb1s" }]
}If the client already exists in Rillet without a Wingspan reference, have a person link it once in Rillet, or read it and send it back with the reference added. Customer updates replace the whole record. See Make every Rillet write safe to retry.
2. Create the invoice
Look first, then create. Search GET /invoices?external_reference_type=wingspan&external_reference_id=<invoice ID> with the current version's reference. The first version uses the bare invoice ID, later versions use <invoice ID>:v2, :v3, and so on, and your sync log records which version is current. Create the invoice only when none matches:
POST /invoices
Idempotency-Key: <key>
{
"scope": "REVENUE_RECOGNITION_ONLY",
"subsidiary_id": "6f1c2a9e-3b7d-4e1a-9c55-0d8e2f4b7a10",
"customer_id": "b41f7c2e-8a3d-4f6b-a1e9-2c5d7e9f0a34",
"invoice_number": "WS-1001",
"invoice_date": "2026-10-07",
"due_date": "2026-11-06",
"external_references": [{ "type": "wingspan", "id": "Dq8kXF2uGZ1J1MPOBhOUkk" }],
"items": [
{
"product_id": "0c9e4d1a-5b2f-4a7c-8e3d-6f1a2b3c4d5e",
"description": "Shift S-101, Priya Shah",
"quantity": 8,
"total_amount": { "amount": "900.00", "currency": "USD" },
"revenue": { "period": { "start": "2026-10-05", "end": "2026-10-05" } },
"fields": [{ "field_id": "a7d3e9f1-2c4b-4d6e-8f0a-1b3c5d7e9f20", "field_value_id": "c2e4f6a8-0b1d-4f3a-9c5e-7d9f1b3d5e70" }],
"external_references": [{ "type": "wingspan", "id": "Dq8kXF2uGZ1J1MPOBhOUkk:1" }]
},
{
"product_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"description": "Platform fee | Shift S-101",
"quantity": 1,
"total_amount": { "amount": "45.00", "currency": "USD" },
"external_references": [{ "type": "wingspan", "id": "Dq8kXF2uGZ1J1MPOBhOUkk:2" }]
}
]
}REVENUE_RECOGNITION_ONLY is Rillet's scope for invoices issued from another system. It takes your invoice number instead of assigning one, and Rillet still recognizes the revenue over each line's revenue.period. Every line needs its own external reference. A stable choice is the invoice ID plus the line's position.
The invoice has no currency field of its own. Its currency is the one on every line's total_amount, and every payment and credit against it must use the same currency.
Rillet's externally issued invoices have no line discount field, so send each line's amount after its discount and keep the gross amount in the description if your controller wants it visible.
Then mark the invoice as sent with PUT /invoices/{invoice_id}/sent-status and {"sent": true}, since Wingspan already delivered it. Compare Rillet's total with the Wingspan invoice total. If they differ, flag the invoice and hold its payments until someone fixes the mapping.
3. Record payments
Check your sync log for the Wingspan payment ID. If it isn't there, record the payment:
POST /invoices/{invoice_id}/payments
Idempotency-Key: <key>
{
"amount": { "amount": "945.00", "currency": "USD" },
"date": "2026-10-08",
"account_code": "1101"
}Store the returned payment id against the Wingspan payment ID right away. Invoice payments in Rillet have no field for your ID. After the idempotency window, find a payment you already sent with GET /invoices/{invoice_id}/payments, or across all invoices with GET /invoice-payments?updated.gt=<timestamp>&sort_by=updated, and match on amount, date, and account. Skip rows with status: FAILED.
Card and ACH payments go to the clearing account. Off-platform payments go to the operating bank account, where they match the bank feed line one for one.
To get a payment's booking date, convert its completedAt timestamp to the subsidiary's time zone, which GET /subsidiaries returns. 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, delete the Rillet payment with DELETE /invoices/{invoice_id}/payments/{payment_id}. The invoice balance reopens. Two cases go to a person instead:
- The payment is cleared. Rillet deletes only uncleared payments. If someone has already reconciled it against a bank transaction, Rillet rejects the delete. Flag it for a person to unreconcile in Rillet, then delete it on the next pass.
- The payment is dated in a closed period. A delete removes the cash from the month the payment was dated in. If that date is on or before the
endofGET /books/periods/last-closed, don't delete it. Flag it for your controller, who books the return in the open period.
5. Refunds
Post a refund as a journal entry tied to the customer. Journal entries have no external reference, so put the Wingspan refund ID in name:
POST /journal-entries
Idempotency-Key: <key>
{
"name": "Wingspan refund rf_1 on invoice WS-1001",
"date": "2026-10-07",
"currency": "USD",
"subsidiary_id": "6f1c2a9e-3b7d-4e1a-9c55-0d8e2f4b7a10",
"related_entity": { "id": "b41f7c2e-8a3d-4f6b-a1e9-2c5d7e9f0a34", "type": "CUSTOMER" },
"items": [
{ "account_code": "4900", "side": "DEBIT", "amount": { "amount": "256.00", "currency": "USD" }, "description": "Refund on WS-1001" },
{ "account_code": "1101", "side": "CREDIT", "amount": { "amount": "256.00", "currency": "USD" }, "description": "Refund on WS-1001" }
]
}6. Credits
A credit on an open invoice is a discount on the line in Wingspan. It reaches Rillet in that line's total_amount.
A credit balance applied to an invoice becomes a Rillet credit memo with the Wingspan credit ID as its external reference. Use your credits product, a quantity of 1, and the credit amount as amount_per_unit:
POST /credit-memos
Idempotency-Key: <key>
{
"customer_id": "b41f7c2e-8a3d-4f6b-a1e9-2c5d7e9f0a34",
"subsidiary_id": "6f1c2a9e-3b7d-4e1a-9c55-0d8e2f4b7a10",
"credit_memo_date": "2026-10-08",
"external_references": [{ "type": "wingspan", "id": "cr_5Tq1" }],
"items": [
{
"description": "Wingspan credit cr_5Tq1 applied to WS-1001",
"price": {
"product_id": "3d5f7a9b-1c2e-4f6a-8b0c-2d4e6f8a0b1c",
"quantity": 1,
"amount_per_unit": { "amount": "300.00", "currency": "USD" }
}
}
]
}Then apply it:
POST /credit-memos/{credit_memo_id}/applications
{
"applications": [
{ "invoice_id": "e3a1c5d7-9b2f-4e6a-8c0d-2f4a6b8c0d1e", "amount": { "amount": "300.00", "currency": "USD" }, "application_date": "2026-10-08" }
]
}This call replaces the memo's applications with the list you send. Any application missing from the list is removed. Read the memo first and send its existing applications along with the new one. See Make every Rillet write safe to retry for the idempotency key on this call.
7. Cancellations and changes
- Cancelled. Rillet has no invoice void, and its delete is meant for drafts. Issue a credit memo that mirrors the invoice: one line per Rillet invoice line with the same
product_id,description,fields, andrevenue.period, so revenue reverses in the periods it was recognized. Setprice.quantityto 1 andprice.amount_per_unitto the line amount. Its external reference is the Wingspan invoice ID, then the version, then:cancel, such as<invoice ID>:v1:cancel. If the invoice isPARTIALLY_PAID, the memo can't exceedamount_due, so send it to a person instead of scaling the lines. Then apply the memo to the invoice. The invoice moves toCREDITED, and the history stays in the ledger. If the original period is closed, send the memo to your controller instead. - Edited before payment. Rillet can change only an externally issued invoice's dates, invoice number, line descriptions, revenue periods, and fields. It can't change a line amount, add a line, or remove one.
- If Wingspan changed only those editable fields, read the invoice, then
PUT /invoices/{invoice_id}. Sendscope: REVENUE_RECOGNITION_ONLY, every item's existingid, and every other field the update accepts (invoice_date,due_date,invoice_number,external_references,exchange_rate) copied from what you read. Any field you leave out is cleared. - If a line amount changed or a line was added or removed, retire the current Rillet invoice with a mirrored credit memo as above, referenced by the version it retires, such as
<invoice ID>:v1:cancel. Then create the invoice again with the new lines under<invoice ID>:v2on the invoice and<invoice ID>:v2:1on its first line, withinvoice:<invoice ID>:v2as the idempotency key. Record v2 as the current version before you record any payment, so payments go to it. Try a repeated invoice number in the sandbox first. If Rillet refuses it, add a suffix such asWS-1001-2. - Decide whether anything changed by comparing what Wingspan sent this time with what it sent last time. If you compare mapped Rillet payloads instead, a mapping change on your side rewrites invoices already in Rillet, and some of those may sit in a closed period.
- If Wingspan changed only those editable fields, read the invoice, then
- Edited after a payment synced. Leave the Rillet invoice alone. Wingspan locks lines once an invoice is paid.
Payouts
Your controller picks one of two ways to book contractor payouts. Rillet's limit of 60 requests a minute matters here. Bill mode takes about three calls per payable, so a run of 500 contractors takes about 1,500 calls, which is 25 minutes of the key's whole allowance.
One journal entry per payroll run
This suits large contractor counts. Post one entry per run: debits to contractor cost, summed by field combination, and one credit to the Wingspan funding account. Name it after the run, such as Wingspan payroll run <payrollRunId>.
Post it once Wingspan reports the run's payables paid, or after a cutoff your controller sets, such as 24 hours after the run's pay date, whichever comes first. Freeze the set of payables in the entry at that moment and record it in your sync log. A payable that pays after the cutoff goes in the next run's entry, and a payable that returns after posting gets its own reversing entry. Never rebuild a posted run's set.
POST /journal-entries
Idempotency-Key: <key>
{
"name": "Wingspan payroll run pr_8Hn2",
"date": "2026-10-09",
"currency": "USD",
"subsidiary_id": "6f1c2a9e-3b7d-4e1a-9c55-0d8e2f4b7a10",
"items": [
{ "account_code": "5100", "side": "DEBIT", "amount": { "amount": "18400.00", "currency": "USD" }, "fields": [{ "field_id": "a7d3e9f1-2c4b-4d6e-8f0a-1b3c5d7e9f20", "field_value_id": "c2e4f6a8-0b1d-4f3a-9c5e-7d9f1b3d5e70" }] },
{ "account_code": "5100", "side": "DEBIT", "amount": { "amount": "6200.00", "currency": "USD" }, "fields": [{ "field_id": "a7d3e9f1-2c4b-4d6e-8f0a-1b3c5d7e9f20", "field_value_id": "d4f6a8c0-2e4a-4b6c-8d0f-3a5c7e9b1d20" }] },
{ "account_code": "1120", "side": "CREDIT", "amount": { "amount": "24600.00", "currency": "USD" } }
]
}Record which payables the entry covered as soon as Rillet 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 Rillet.
-
Find or create the vendor, with the payee ID as its external reference. The vendor list can't filter by reference, so load the vendor list into your sync log once, then keep it current with
updated.gt. If the contractor already exists in Rillet without a Wingspan reference, have a person link it once, as with customers, or the sync creates a second vendor. Wingspan files the 1099s and holds each contractor's tax ID, so leavetax_idempty. Ask your controller whether to setten_ninety_nine_eligible, since it puts the vendor in Rillet's 1099 reports too. -
Create the bill:
POST /bills Idempotency-Key: <key> { "vendor_id": "f8e7d6c5-b4a3-4921-8f7e-6d5c4b3a2918", "expense_number": "WS-PAY-4471", "bill_date": "2026-10-09", "due_date": "2026-10-09", "subsidiary_id": "6f1c2a9e-3b7d-4e1a-9c55-0d8e2f4b7a10", "external_references": [{ "type": "wingspan", "id": "pay_4471" }], "items": [ { "account_code": "5100", "amount": { "amount": "1200.00", "currency": "USD" }, "description": "Shift S-101", "fields": [{ "field_id": "a7d3e9f1-2c4b-4d6e-8f0a-1b3c5d7e9f20", "field_value_id": "c2e4f6a8-0b1d-4f3a-9c5e-7d9f1b3d5e70" }] } ] } -
Check that the bill has no
pending_approval_request. If a bill is waiting for approval, flag it and skip the payment. -
Work out what's still due. The bill has no amount due field. If its
statusisPAID,CREDITED, orAPPLIED, skip the payment. Otherwise add up itsitems, subtract the payments fromGET /bills/{bill_id}/payments, and subtract anyapplicationsfor thisbill_idonGET /vendor-credits?vendor_id. Someone in Rillet may already have paid part of it. -
Pay that amount from the Wingspan funding account. Send
billIdwith the same bill ID as the path. Rillet's schema leaves it out, but the endpoint still reads this legacy field and rejects a mismatch:POST /bills/{bill_id}/payments Idempotency-Key: <key> { "billId": "2b4d6f8a-0c1e-4a3b-9d5f-7a9c1e3b5d70", "amount": { "amount": "1200.00", "currency": "USD" }, "date": "2026-10-09", "account_code": "1120" } -
Store the returned payment
idagainst the payable.
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 and named
Wingspan return <payableId>. If the payable never made it into a posted run, leave it out of the next one instead. - Bill mode. Delete the payment you recorded with
DELETE /bills/{bill_id}/payments/{payment_id}and leave the bill open. If Wingspan pays the payable again, the sync pays the same bill. If Wingspan cancels the payable instead, reverse the cost with a vendor credit. Give it one line per bill item with the sameaccount_code,amount, andfields. Setdateandgl_impact_dateto the return date,credit_numberto the expense number plus-R, andvendor_idandsubsidiary_idfrom the bill. Apply it withPOST /vendor-credits/{vendor_credit_id}/applicationsandapplications: [{bill_id, amount}]. If the bill also carries a payment someone added in Rillet, delete only yours, leave the bill open, and flag it for a person. - Closed periods. If the payment you'd delete or the entry you'd reverse is dated on or before the
endofGET /books/periods/last-closed, flag it for your controller, who books the return in the open period.
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 subsidiary that moves the spread from pass-through to revenue. Freeze each batch of jobs before you post it, so a retry posts exactly the same set under the same name.
Never post the bank side
Rillet's bank feed imports your bank activity, and your team reconciles it against payments. The sync records payments and journal entries, and it never creates bank transactions. A bank transaction from the sync would sit beside the one the feed imports, and the cash would count twice.
Collections land in the clearing account. When Wingspan deposits a batch to your bank, the deposit comes in through the feed, and your team records it as a transfer out of clearing. When Wingspan pulls funding from your bank for payroll, the pull comes in through the feed too, and your team records it as a transfer into the Wingspan funding account. Payout runs are the one cash entry the sync posts, because your Wingspan funding balance has no bank feed.
Make every Rillet write safe to retry
Rillet accepts an Idempotency-Key header on every POST. Rillet saves the first response for 24 hours and returns it for any repeat of the same key, without comparing the body. Build the key from the Wingspan object, for example a UUID v5 of invoice:<invoice ID>, so a retry from a fresh process sends the same key. Use a different key for each kind of write against the same object, such as payment:<payment ID> and refund:<refund ID>.
- A request that failed validation isn't saved, so you can fix it and send it again with the same key.
- A
409means the first request with that key is still running. Wait and retry with the same key. - Before you send any create, write a pending row to your sync log with the key and the current time. If your sync crashes after Rillet saved the object and before your sync log recorded its ID, check that row on restart. If it's under 23 hours old, send the same POST with the same key before any lookup. Rillet returns the saved response, including the Rillet
id. That is how you recover IDs for payments, journal entries, and vendor credits, which have no external reference. If the row is older, use the lookup table below first and create only if nothing matches. - Put a fixed key only on calls that create one object.
POST /credit-memos/{credit_memo_id}/applicationsreplaces the memo's application list, and you may call it more than once a day for the same memo. Send it with no key, or derive the key from the full body you send. After any replay, compare the returned applications with what you meant to send. - After 24 hours the key creates a new object. Before you retry anything older, look it up:
| Object | Find it with | Match on |
|---|---|---|
| Customer | GET /customers?external_reference_type&external_reference_id | External reference |
| Invoice | GET /invoices?external_reference_type&external_reference_id | External reference |
| Invoice payment | Sync log, then GET /invoices/{invoice_id}/payments or GET /invoice-payments?updated.gt | Amount, date, and account_code, not FAILED |
| Credit memo | Sync log, then GET /credit-memos?subsidiary_id&updated.gt, every page | External reference |
| Vendor | Sync log, then GET /vendors, every page | External reference |
| Bill | GET /bills?vendor_id, every page | External reference or expense_number |
| Bill payment | GET /bills/{bill_id}/payments | Amount and date |
| Vendor credit | GET /vendor-credits?vendor_id, every page | credit_number |
| Journal entry | GET /journal-entries?subsidiary_id&date.gte&date.lte, every page | name |
Updates use PUT and replace the whole record: invoices, customers, vendors, bills, and credit memos. Read the record, change what you need, and send all of it back. A PUT built from only the fields you changed clears the rest. Metadata is the exception, and has its own PATCH endpoints. Rillet has no If-Match, so re-read the record right before you write, and skip the write if its updated_at moved since the read your change was based on. A person in Rillet may have edited it.
Rillet allows 60 requests in any rolling minute, then returns 429 with no Retry-After. Run every Rillet call through one shared limiter, since webhook workers, the event poller, and the sweep all draw on the same key. On a 429, pause every caller for a full minute. Check the sync log before Rillet, and look things up in Rillet only on a sync log miss. Budget about five calls per new invoice (lookup, create, sent status, payment, and a customer when it's new) and three per bill-mode payable. At that rate the key moves about 12 invoices a minute, and a backfill of 5,000 open invoices takes about seven hours. Cache the chart of accounts, products, fields, and subsidiaries, and ask for limit=100 on every list.
Page with cursor and pagination.next_cursor. A cursor older than two hours doesn't fail. Rillet starts a new sequence from the first page, so remember the first page's IDs and stop if you see them again. Lists come back newest first, so an incremental walk with updated.gt should set its watermark to the highest updated_at it saw, not to the clock. Errors come back as problem details JSON with title and detail, which belong in your sync log.
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": "Rillet sync"
}- Store the
secretfrom the response. Wingspan returns it once. - Check the
Wingspan-Signatureheader on every push, and skip any event whose ID you've already processed. See Verify webhook signatures. - Answer with a
2xxquickly and do the Rillet 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 whoseevents.updatedAthasn't changed since the last sync.
Read each resource with its own Account in X-Wingspan-Account. See Acting on behalf of Accounts.
Rillet sends webhooks of its own. A one-way sync doesn't need them.
Subsidiaries and currencies
- Map one Wingspan Account to one Rillet subsidiary, and send its
subsidiary_idon every invoice, credit memo, bill, vendor credit, and journal entry. A US subsidiary and a Canadian subsidiary each get their own clearing and funding accounts, each registered as a bank account in that subsidiary. - Post in the transaction's currency. Journal entries also take a top-level
currency. For a transaction in a currency other than the subsidiary's, agree with your controller whether to send Wingspan's rate inexchange_rate, which takesbase,target,rateas a decimal string, anddate. Rillet's spec doesn't say which way the rate runs, so confirm the direction in the sandbox with a known rate before go-live. - Book dates in the subsidiary's time zone, except off-platform payment dates, which are already calendar dates.
- Check
GET /books/periods/last-closedbefore you post or delete anything. An item dated on or before itsendgoes to your controller instead of the ledger. A404means no period has been closed yet.
Pitfalls
- Pin the API version. Without
X-Rillet-API-Version, requests use Rillet's latest version. - Create the external reference type first. Rillet rejects a reference whose
typedoesn't exist. - Every invoice line needs an external reference and a product. A line missing either fails the whole invoice.
- An invoice update can't change amounts or lines. Cancel with a mirrored credit memo and create a new version.
PUTclears what you leave out. Read the record first, send all of it back, and skip the write if a person changed it since.- Credit memo applications replace the full list. Send the existing applications with the new one, and don't put a fixed idempotency key on that call.
- Credit memos should mirror the invoice lines. Otherwise revenue reverses in the wrong period and loses its fields.
- Payments, journal entries, and vendor credits carry no Wingspan ID. Record their Rillet IDs the moment Rillet returns them, and replay the POST only while the first attempt is under 23 hours old, inside Rillet's 24-hour window.
- Bill payments need
billIdin the body. It must match the bill ID in the path. - A cleared payment can't be deleted, and a payment in a closed period shouldn't be. Flag both for a person.
- Rillet has no invoice void. Cancel with a credit memo.
- Turn off Rillet's invoice emails for synced customers. Wingspan already sent the invoice.
- Journal entry approvals can't exclude the sync. Confirm no journal entry workflow matches before go-live. Bill workflows can exclude
PUBLIC_API. - A bill with line allocations can't be updated through the API. Rillet returns
422 BillHasSubsidiaryAllocations, even for a no-op. Edit it in Rillet. - Money is a decimal string inside an object.
{"amount": "945.00", "currency": "USD"}, rounded to the currency. - Off-platform payment dates are midnight UTC. Use the date part.
- Never create bank transactions. The bank feed already imports them.
- 60 requests a minute, with no
Retry-After. Use one shared limiter, prefer one journal entry per run for large payrolls, and cache reference data. - An expired cursor restarts from page one without an error. Watch for repeated IDs.
- A mapping change shouldn't rewrite history. Compare Wingspan's data to decide whether to update an invoice, and leave paid invoices alone.
Before you go live
- Agree the mapping with your controller: subsidiaries, accounts, products, fields, revenue mode (gross or net), payout mode (journal or bill), the journal mode cutoff, how returns are booked, approval workflows, and the cutover date.
- Create the external reference type, accounts, bank accounts, products, and field values.
- Run one pay cycle end to end against Rillet's sandbox, including a refund, a return, a cancelled invoice, and an invoice edited after it synced, and compare totals.
- Turn on the sync with the event log starting at its head, then backfill invoices that were open at cutover. Plan the backfill around the rate limit.
- Run alongside your current process for one pay cycle before you switch.
Related pages
Updated 13 days ago