This playbook is for finance and billing teams that want to automate the monthly premium cycle. You generate a group's premium invoice, check its totals, and import a bank payment file (CSV) that creates the payments and posts them to the invoice, without keying payments by hand.
| Who this is for | Premium billing and finance teams, and the developers who automate their bank files |
| Time to run | About 15 minutes |
| Writes data? | Yes. Try it in a non-production database first if you have one. "Try it" calls in the API reference are real production calls. |
| Operations used | 9, linked in each step |
VBAPI clients connect to production, so the write calls on this page create or change real records in the database you name in vbasoftware-database. Try the steps in a non-production database first if VBA has given you one, use clearly fake values while you test, and keep the "Check it worked" calls, which only read data. "Try it" in the API reference also calls production.
- You have completed the quick start and your environment variables are set.
- A test group with enrolled subscribers, premium rates, and a billing cycle already exists. The samples use the fake group
DOCS-GRP1; replace it at the top of each sample. - The group is not set up for self-admin billing. Self-admin groups use a different set of operations that this playbook does not cover.
- You know the payor and account that payments should be recorded against.
GET /payor-accountslists each payor with itsaccount_Key. - A premium payment file format is set up in VBASoftware.
- Your API user has rights to premium billing and premium payments.
GET /groups/{groupID}, then GET /billing-cycles/{billingCycleID}
The group record says which billing cycle its invoices use (billing_Cycle_ID) and whether it is a self-admin group (self_Admin). The billing cycle tells you how often the group is billed, so you can pick the right billing period in step 2. To see every cycle, use GET /billing-cycles. Both calls only read data.
var group = (await api.GetAsync($"/groups/{GroupId}")).Data!;
if (group["self_Admin"]?.GetValue<bool>() == true)
throw new InvalidOperationException($"{GroupId} uses self-admin billing, which this playbook does not cover.");
var cycle = (await api.GetAsync($"/billing-cycles/{group["billing_Cycle_ID"]}")).Data!;
Console.WriteLine($"Billing cycle: {cycle["billing_Cycle_ID"]} {cycle["description"]}");
This builds one premium invoice for the group for the billing period you give it, and returns it. The GeneratePremInvoice schema marks no field as required, but the API source (FABilling/Services/BillingService.cs) rejects a request with 400 unless group_ID and bill_From are set, and requires division_ID and subscriber_ID to be empty for a group invoice. The minimal body:
{
"group_ID": "DOCS-GRP1",
"bill_From": "2030-02-01T00:00:00",
"bill_Thru": "2030-02-28T00:00:00",
"invoice_Date": "2030-01-15T00:00:00",
"due_Date": "2030-02-01T00:00:00",
"regenerate": false
}Do not send user_ID: VBAPI sets it to the API user. Use the billing period you actually bill; the 2030 dates are placeholders.
var invoice = (await api.PostAsync("/generate-group-invoice", new
{
group_ID = GroupId,
bill_From = "2030-02-01T00:00:00",
bill_Thru = "2030-02-28T00:00:00",
invoice_Date = "2030-01-15T00:00:00",
due_Date = "2030-02-01T00:00:00",
regenerate = false,
})).Data!;
var invoiceKey = invoice["invoice_Key"]!.GetValue<int>();
Console.WriteLine($"Invoice {invoiceKey} number {invoice["invoice_Number"]} status {invoice["invoice_Status"]}");
Example response (illustrative; field values are fake, and some fields are left out):
{
"data": {
"invoice_Key": 900001,
"invoice_Number": "DOCS-900001",
"invoice_Level": "G",
"invoice_Status": "OPEN",
"billing_Cycle_ID": "MO",
"billing_From": "2030-02-01T00:00:00",
"billing_Thru": "2030-02-28T00:00:00",
"invoice_Date": "2030-01-15T00:00:00",
"due_Date": "2030-02-01T00:00:00",
"bill_Name": "Sample Employer Group",
"finalized": false
},
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Save from the response: data.invoice_Key (the invoiceKey path parameter, and the Invoice_Key column in the payment file).
Running it again: each call with regenerate set to false takes a new invoice key, so a second call creates a second invoice for the same period. To rebuild an existing invoice, use POST /invoice-regenerate/{invoiceKey}, which deletes the invoice and its attached adjustments and payments and rebuilds it under the same key. With regenerate set to true, this operation reuses the invoice_Key you send instead of taking a new one.
GET /premium-invoices/{invoiceKey}/summary
The summary totals the invoice: premium, premiumReceived, postedPayment, unpostedAmount, the adjustment totals, amountDue, and subscriber and member counts. Check that the counts and premium match what you expect before you send the invoice or apply money to it. GET /premium-invoices/{invoiceKey} returns the invoice header. Both calls only read data.
var summary = (await api.GetAsync($"/premium-invoices/{invoiceKey}/summary")).Data!;
Console.WriteLine($"Premium: {summary["premium"]} Amount due: {summary["amountDue"]} Subscribers: {summary["subscriberCount"]}");
GET /premium-payment-file-formats
The payment file request needs a payment_File_Format_Key. List the formats and use the one your administrator set up for bank files. The samples print every format and use DOCS_PAYMENT_FILE_FORMAT_KEY if it is set, otherwise the first one.
var formats = (await api.GetAsync("/premium-payment-file-formats")).Data?.AsArray() ?? new JsonArray();
foreach (var f in formats)
Console.WriteLine($"File format {f?["payment_File_Format_Key"]} {f?["description"]}");
var formatKey = int.TryParse(Environment.GetEnvironmentVariable("DOCS_PAYMENT_FILE_FORMAT_KEY"), out var configured)
? configured
: formats[0]!["payment_File_Format_Key"]!.GetValue<int>();
Save from the response: payment_File_Format_Key.
The payment file is a CSV with a header row. These are the columns from the VBA_ProcessPaymentFile schema:
| Column | Required | Meaning |
|---|---|---|
Payment_Account | Yes | Name of the paying account |
Payment_ID | Yes | Your unique ID for the payment, for example the bank reference |
Payment_Date | Yes | Payment date, YYYY-MM-DD |
Payment_Amount | Yes | Amount of the payment |
Payment_Name | Yes | Name of the payer |
Payor_ID | Yes | Payor for the payment; the request's payor_ID overrides it |
Account_Key | Yes | Account for the payment; the request's account_Key overrides it when above 0 |
Payment_Type | No | A value from the premium payment types table |
Payment_Batch, Payment_Batch_Seq | No | Batch and sequence from your source system |
Group_ID, Division_ID, Subscriber_ID | No | Who the payment is from |
Invoice_Key | No | Invoice to post the payment to |
PremRate_Key | No | Rate to apply the payment to |
PremPayment_Key | No | Existing payment to apply the payment to |
This sample file has two fake payments: one for the invoice from step 2 and one with no invoice. The {{INVOICE_KEY}} placeholder is replaced with the real invoice key before the file is sent; a file with the placeholder still in it is rejected.
Payment_Type,Payment_Batch,Payment_Batch_Seq,Payment_Account,Payment_ID,Payment_Date,Payment_Amount,Payment_Name,Group_ID,Division_ID,Subscriber_ID,Payor_ID,Account_Key,Invoice_Key,PremRate_Key,PremPayment_Key
,DOCS-BATCH-1,1,DOCS Sample Bank Account,DOCS-PAY-0001,2030-02-01,250.00,Sample Employer Group,DOCS-GRP1,,,DOCS-PAYOR,0,{{INVOICE_KEY}},,
,DOCS-BATCH-1,2,DOCS Sample Bank Account,DOCS-PAY-0002,2030-02-01,75.00,Sample Employer Group,DOCS-GRP1,,,DOCS-PAYOR,0,,,The request sends the file as a base64-encoded string in payment_File:
var csvText = File.ReadAllText(SamplePath("payments-sample.csv"))
.Replace("{{INVOICE_KEY}}", invoiceKey.ToString());
var paymentFile = Convert.ToBase64String(Encoding.UTF8.GetBytes(csvText));
This creates one premium payment for every row in the file and, depending on four options, posts the payments to invoices, applies them, and closes invoices. It returns 200 when it has finished. As with step 2, do not send user_ID; VBAPI sets it.
What the four options do, from BillingService.ProcessPaymentFile in the API source:
| Option | When true |
|---|---|
create_Invoice_Payments | For each row with an Invoice_Key, posts the row's full Payment_Amount to that invoice. Rows without an Invoice_Key only create a payment. |
apply_Payments | Runs auto-apply on each invoice in the file, the same as POST /premium-invoices/{invoiceKey}/auto-apply. |
close_Invoices | Recalculates the amount due on each invoice in the file. An invoice with nothing due is closed. An overpaid invoice goes through the invoice tolerance process and stays open. For an invoice that still has a balance, see the next row. |
only_Close_Satisfied_Invoices | Leaves invoices that still have a balance open. When this is false and close_Invoices is true, VBAPI posts an adjustment for the remaining balance and closes the invoice anyway, so the unpaid amount is written off. |
A safe first run: create the payments and post them to invoices, but do not apply them or close anything. This is what the samples send:
{
"payment_File_Format_Key": 1,
"payment_File": "UGF5bWVudF9UeXBlLFBheW1lbnRfQmF0Y2gs...",
"payor_ID": "DOCS-PAYOR",
"account_Key": 0,
"create_Invoice_Payments": true,
"apply_Payments": false,
"close_Invoices": false,
"only_Close_Satisfied_Invoices": true
}After you have checked the result (step 7), apply the payments with auto-apply. Once you trust the file and the invoices, you can let the import apply and close in one run by setting apply_Payments and close_Invoices to true. Keep only_Close_Satisfied_Invoices set to true unless your finance team wants unpaid balances written off.
var result = (await api.PostAsync("/process-payment-file", new
{
payment_File_Format_Key = formatKey,
payment_File = paymentFile,
payor_ID = PayorId,
account_Key = AccountKey,
create_Invoice_Payments = true,
apply_Payments = false,
close_Invoices = false,
only_Close_Satisfied_Invoices = true,
})).Data!;
Console.WriteLine($"Payments created: {result["payment_Created_Count"]}");
Console.WriteLine($"Invoice payments posted: {result["invoice_Payment_Posted_Count"]}");
Console.WriteLine($"Invoices closed: {result["invoice_Closed_Count"]} left open: {result["invoice_Left_Open_Count"]}");
Example response for the sample file and the safe first run (illustrative):
{
"data": {
"payment_Created_Count": 2,
"invoice_Payment_Posted_Count": 1,
"invoice_Closed_Count": 0,
"invoice_Left_Open_Count": 1
},
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}How to read the counts:
payment_Created_Count: rows in the file. Every row creates a payment, whatever the options.invoice_Payment_Posted_Count: rows posted to an invoice. Zero whencreate_Invoice_Paymentsisfalse.invoice_Closed_Countandinvoice_Left_Open_Count: the outcome for each row that has anInvoice_Key. Withclose_Invoicesset tofalse, every such row counts as left open.
Invoices left open: compare amountDue in the invoice summary (step 7). A short payment needs follow-up with the group, or an adjustment if your finance team approves one. An overpayment has been through the tolerance process; check the invoice in VBASoftware.
POST /premium-invoices/{invoiceKey}/auto-apply, then GET /premium-invoice-payments-ext/{invoiceKey}
Auto-apply spreads the payments posted to the invoice across its premium lines. It returns 202 and runs in the background, so wait a moment before you check. The samples only call it when DOCS_APPLY_PAYMENTS is true.
The extended invoice payment list shows each payment posted to the invoice with paymentPosted and paymentApplied, and the summary shows the new postedPayment and amountDue. Both calls only read data.
if (Environment.GetEnvironmentVariable("DOCS_APPLY_PAYMENTS") == "true")
{
var accepted = await api.PostAsync($"/premium-invoices/{invoiceKey}/auto-apply");
Console.WriteLine($"Auto-apply accepted: {(int)accepted.Status}"); // 202; it runs in the background
}
await foreach (var p in api.GetAllAsync($"/premium-invoice-payments-ext/{invoiceKey}"))
Console.WriteLine($"Payment {p?["paymentId"]} amount {p?["paymentAmount"]} posted {p?["paymentPosted"]} applied {p?["paymentApplied"]}");
var after = (await api.GetAsync($"/premium-invoices/{invoiceKey}/summary")).Data!;
Console.WriteLine($"Posted payment: {after["postedPayment"]} Amount due: {after["amountDue"]}");
These calls only read data:
GET /premium-invoices/{invoiceKey}/summary:postedPaymentincludes the posted amount, andamountDuehas gone down once payments are applied.GET /premium-invoice-payments-ext/{invoiceKey}: one entry per payment posted to the invoice, with itspremPaymentKeyandpaymentId.GET /premium-payments/{premPaymentKey}: the payment record, with the payor and account it was recorded against.GET /premium-paymentsandGET /premium-invoice-paymentslist every payment and invoice payment in the database. They have no filter parameters, so use the per-invoice list above where you can, and page through these when you need a payment that was not posted to an invoice.
- Remove a payment from the invoices it was posted to with
POST /undo-premium-payment/{premPaymentKey}(202). It recalculates the received amounts on each affected invoice, and returns400if the payment was not posted to any invoice. - Delete a test payment with
DELETE /premium-payments/{premPaymentKey}(204). Undo it first if it was posted to an invoice. - Delete a test invoice with
POST /invoice-full-delete/{invoiceKey}(202). It requires thedeleteAdditionalInvoicesandkeepManualAdjustmentsquery parameters.
| Symptom | Likely cause | Fix |
|---|---|---|
400 "You must pass in a valid Group ID" or "valid Bill From" | group_ID or bill_From is missing | Send both |
400 "the Division ID must be blank" | division_ID or subscriber_ID was sent to the group invoice operation | Leave them out, or use the division variation below |
404 "PremInvoice not found" from POST /generate-group-invoice | Nothing was billable for the period, so no invoice was created. A division copied with copyPremiums does not bring the template's group-level premium rates | Check the group's rates with GET /included-premium-rates?groupId=... and the enrollment dates |
| An error from the invoice summary | The invoice has no subscriber or self-admin lines, for example because nobody was enrolled for the period | Check the billing period and the group's enrollment |
400 "Invalid payment file" | payment_File is empty, is not base64, or has no data rows | Base64-encode the whole CSV, header row included |
| An error when the file is processed | A column has the wrong type, for example the {{INVOICE_KEY}} placeholder was not replaced | Fix the row; numbers and dates must parse |
| Payments created twice | The same file was imported twice | Each import creates a payment per row; undo and delete the duplicates |
403 with {"message":"Forbidden"} on every call | The x-api-key header is missing or wrong | See errors |
Send VBA support:
- the
debug.activityIDfrom the response - the date and time of the call, with time zone
- the method and path, for example
POST /claim-batches - the full
errorobject from the response - the request body, with passwords, tokens, and personal data removed
Never send your password, API key, or tokens. See Support and escalation.
- Group and division invoices together:
POST /generate-group-division-invoicetakes the same body. It creates the group invoice for divisions billed at group level and a separate invoice for each division billed at division level. It returns202with no body, so find the new invoices afterwards withGET /premium-invoices-slimand itsgroupIdandinvoiceYearfilters. - Safe to re-run? No. Generating again creates another invoice, and importing the same file again creates the payments again. Keep track of the invoice keys and
Payment_IDvalues you have already sent.
The complete runnable samples are in the documentation repository under _samples/playbooks/premium-billing-and-payments/: PremiumBillingPlaybook.cs, premium_billing.py, premiumBilling.ts, and the sample payment file payments-sample.csv, which each sample reads from its own folder. Each sample uses the shared helper for its language described in the quick start.
- Concepts: data model, requests and responses, pagination
- Previous playbook: Enter claims and adjudicate them
- Ask the AI: "Using the Bill premiums and apply payments playbook, write a C# function that turns our bank's lockbox file into the VBAPI payment file and imports it without closing invoices."