This playbook is for HR and benefits integrators and enrollment teams who load new hires or a census into VBASoftware. At the end, each family is a subscriber with members, enrolled in a group contract and plan, with an ID card request on file.
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 finished the quick start and can get an ID token. The samples read your credentials from environment variables; see Set up your environment.
- The employer's group and division exist, with at least one contract and a plan on it. The samples use
DOCS-GRP1andDOCS-DIV1from Onboard a new employer group. - The API user can create subscribers, members, and enrollments, and create ID card requests.
- You have read the subscribers, members, and enrollments part of the data model.
A subscriber is the family unit, identified by subscriber_ID. Members are the people in it, identified by subscriber_ID plus a two-character member_Seq. One member is the insured (the employee). The family's enrollment ties the subscriber to a group contract for a date range, and enrollment plans say which plan they are covered by.
The samples use the fake subscriber DOCS-0001. subscriber_ID allows only 9 characters, so check the length of your own IDs.
GET /groups/{groupID}/divisions/{divisionID}/contracts and GET /groups/{groupID}/divisions/{divisionID}/contracts/{coverageStart}/plans
Enrollment points at a specific group contract, identified by its coverage_Start. Read the division's contracts, choose the one that covers the employee's start date (the sample takes the latest), and read the plans offered on it. Pass coverageStart in the path as a date, for example 2030-01-01.
var contracts = new List<JsonNode>();
await foreach (var c in api.GetAllAsync($"/groups/{GroupId}/divisions/{DivisionId}/contracts"))
contracts.Add(c!);
var contract = contracts.OrderBy(c => c["coverage_Start"]!.GetValue<string>(), StringComparer.Ordinal).Last(); // the latest contract
var coverageStart = DatePart(contract["coverage_Start"]);
var coverageEnd = DatePart(contract["coverage_End"]);
if (coverageEnd.Length == 0)
throw new InvalidOperationException("The contract has no coverage_End. add-enrollment needs an end_Date; set one for this sample.");
var groupPlans = (await api.GetAsync($"/groups/{GroupId}/divisions/{DivisionId}/contracts/{coverageStart}/plans")).Data!.AsArray();
var planId = groupPlans[0]!["plan_ID"]!.GetValue<string>();
Console.WriteLine($"Contract {coverageStart} to {coverageEnd} plan {planId}");
Save from the response: the contract's coverage_Start and coverage_End (steps 5 and 7), and a plan_ID (step 7).
Relationship codes are configured per database, so look them up rather than hardcoding them. The relationship whose subscriber flag is "1" marks the insured; VBAPI uses it to decide which member is the employee. The one whose spouse flag is "1" is the spouse. Other dependents (children, for example) use the code whose description matches.
var relationships = (await api.GetAsync("/relationships")).Data!.AsArray();
var insuredCode = relationships.First(r => r!["subscriber"]?.GetValue<string>() == "1")!["relate_Code"]!.GetValue<string>();
var spouseCode = relationships.First(r => r!["spouse"]?.GetValue<string>() == "1")!["relate_Code"]!.GetValue<string>();
Console.WriteLine($"Insured relationship: {insuredCode} spouse relationship: {spouseCode}");
Save from the response: the insured and spouse relate_Code values (step 4).
The Subscribers schema has no required fields. Send the family's ID, name, and address:
{
"subscriber_ID": "DOCS-0001",
"first_Name": "Sample",
"last_Name": "Member",
"address": "123 Example St",
"city": "Exampleville",
"state": "TX",
"zip_Code": "00000"
}If you leave out subscriber_ID, VBAPI generates a 9-character ID and returns it; GET /subscribers-generate-id also returns one. Creating a subscriber does not create any member, not even the employee, so step 4 is always needed.
if (await ExistsAsync(api, $"/subscribers/{SubscriberId}"))
{
Console.WriteLine($"Subscriber {SubscriberId} already exists; skipping create");
}
else
{
var subscriber = (await api.PostAsync("/subscribers", new
{
subscriber_ID = SubscriberId,
first_Name = "Sample",
last_Name = "Member",
address = "123 Example St",
city = "Exampleville",
state = "TX",
zip_Code = "00000",
})).Data!;
Console.WriteLine($"Created subscriber {subscriber["subscriber_ID"]}");
}
Save from the response: subscriber_ID (every later step).
Check it worked: GET /subscribers/{subscriberID} returns 200.
POST /subscribers/{subscriberID}/members
Create the employee first, with the insured relationship code, then each dependent. subscriber_ID in the body must match the path. Members has six required booleans; false is the usual value for a new enrollee:
{
"subscriber_ID": "DOCS-0001",
"first_Name": "Sample",
"last_Name": "Member",
"relationship": "INSURED_CODE_FROM_STEP_2",
"adult_Dependent": false,
"continue_Coverage": false,
"initial_Volume_Salary_Pct": false,
"other_Insurance": false,
"smoker": false,
"use_Member_Plan_Year": false
}Leave out member_Seq and VBAPI assigns the next number in the family: 01 for the first member, then 02, and so on, up to 99. Creating the employee first gives them 01. Add birth_Date, sex, and the other fields your plans' eligibility rules use; the Members schema lists them.
var members = (await api.GetAsync($"/subscribers/{SubscriberId}/members")).Data?.AsArray()
.Select(m => m!).ToList() ?? new List<JsonNode>();
var existing = members.Select(m => m["relationship"]?.GetValue<string>()).ToHashSet();
var people = new[]
{
(FirstName: "Sample", Relationship: insuredCode), // the insured: create first
(FirstName: "Sample Spouse", Relationship: spouseCode),
};
foreach (var person in people)
{
if (existing.Contains(person.Relationship))
continue;
var member = (await api.PostAsync($"/subscribers/{SubscriberId}/members", new
{
subscriber_ID = SubscriberId,
first_Name = person.FirstName,
last_Name = "Member",
relationship = person.Relationship,
// Required booleans.
adult_Dependent = false,
continue_Coverage = false,
initial_Volume_Salary_Pct = false,
other_Insurance = false,
smoker = false,
use_Member_Plan_Year = false,
})).Data!;
members.Add(member);
Console.WriteLine($"Created member {member["member_Seq"]} {member["first_Name"]}");
}
var insuredSeq = members.First(m => m["relationship"]?.GetValue<string>() == insuredCode)["member_Seq"]!.GetValue<string>();
Example response (illustrative; field values are fake):
{
"data": {
"subscriber_ID": "DOCS-0001",
"member_Seq": "01",
"first_Name": "Sample",
"last_Name": "Member",
"relationship": "INSURED_CODE_FROM_STEP_2",
"adult_Dependent": false,
"continue_Coverage": false,
"initial_Volume_Salary_Pct": false,
"other_Insurance": false,
"smoker": false,
"use_Member_Plan_Year": false
},
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Save from the response: each member's member_Seq (the employee's is used in step 9).
Check it worked: GET /subscribers/{subscriberID}/members lists every member.
POST /subscribers/{subscriberID}/add-enrollment
This creates the family's enrollment in the group contract. subEnrollment names the contract by group_ID, division_ID, and group_Coverage_Start (the contract's coverage_Start), and the family's coverage dates by start_Date and end_Date.
{
"requesting_User_ID": "your-api-username",
"addMembers": true,
"subEnrollment": {
"subscriber_ID": "DOCS-0001",
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"group_Coverage_Start": "2030-01-01T00:00:00",
"start_Date": "2030-01-01T00:00:00",
"end_Date": "2030-12-31T00:00:00"
}
}requesting_User_IDis required (up to 20 characters). The samples send the API user name.end_Dateis not marked required in the schema, but VBAPI rejects the request without it. Use the contract'scoverage_Endunless the employee's coverage ends sooner.addMembers: trueincludes the dependents. Withfalse, only the insured member is made active on the enrollment.- VBAPI checks that the subscriber exists, that at least one member exists, and that the group contract exists, and returns
400with a message if not.
The response is 202 Accepted with no body.
if (await FindEnrollmentAsync(api, coverageStart) is not null)
{
Console.WriteLine($"Already enrolled in {GroupId} {DivisionId} {coverageStart}");
}
else
{
var response = await api.PostAsync($"/subscribers/{SubscriberId}/add-enrollment", new
{
requesting_User_ID = requestingUserId,
addMembers = true,
subEnrollment = new
{
subscriber_ID = SubscriberId,
group_ID = GroupId,
division_ID = DivisionId,
group_Coverage_Start = $"{coverageStart}T00:00:00",
start_Date = $"{coverageStart}T00:00:00",
end_Date = $"{coverageEnd}T00:00:00",
},
});
Console.WriteLine($"add-enrollment returned {(int)response.Status}"); // 202 Accepted, no body
}
GET /subscribers/{subscriberID}/enrollments
A 202 has no body, so read the enrollment back. The sample checks a few times, two seconds apart, before it gives up. Match on group_ID, division_ID, and group_Coverage_Start.
JsonNode? enrollment = null;
for (var attempt = 0; attempt < 5 && enrollment is null; attempt++)
{
enrollment = await FindEnrollmentAsync(api, coverageStart);
if (enrollment is null)
await Task.Delay(TimeSpan.FromSeconds(2));
}
if (enrollment is null)
throw new InvalidOperationException("The enrollment did not appear. Check the subscriber in VBASoftware.");
var startDate = DatePart(enrollment["start_Date"]);
Console.WriteLine($"Enrollment {enrollment["group_ID"]} {enrollment["division_ID"]} {startDate} to {DatePart(enrollment["end_Date"])}");
Example response (illustrative; field values are fake):
{
"data": [
{
"subscriber_ID": "DOCS-0001",
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"group_Coverage_Start": "2030-01-01T00:00:00",
"start_Date": "2030-01-01T00:00:00",
"end_Date": "2030-12-31T00:00:00"
}
],
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Save from the response: start_Date (with group_Coverage_Start, it identifies the enrollment in step 7).
add-enrollment does not choose a plan. Add one for each plan the family elected. The path identifies the enrollment from step 6; pass both dates as dates, for example 2030-01-01. plan_ID must be a plan on the group contract (step 1).
{
"subscriber_ID": "DOCS-0001",
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"group_Coverage_Start": "2030-01-01T00:00:00",
"start_Date": "2030-01-01T00:00:00",
"plan_ID": "PLAN_ID_FROM_STEP_1",
"plan_Start": "2030-01-01T00:00:00",
"plan_End": "2030-12-31T00:00:00",
"use_Volume_Scale": false
}use_Volume_Scale is the one required boolean; it matters for life and disability volume plans. tier sets the family's coverage tier when the plan is tiered.
var enrolledPlans = (await api.GetAsync(plansPath)).Data?.AsArray() ?? new JsonArray();
if (enrolledPlans.Any(p => p!["plan_ID"]!.GetValue<string>() == planId))
{
Console.WriteLine($"Plan {planId} already on the enrollment");
}
else
{
await api.PostAsync(plansPath, new
{
subscriber_ID = SubscriberId,
group_ID = GroupId,
division_ID = DivisionId,
group_Coverage_Start = $"{coverageStart}T00:00:00",
start_Date = $"{startDate}T00:00:00",
plan_ID = planId,
plan_Start = $"{startDate}T00:00:00",
plan_End = $"{coverageEnd}T00:00:00",
use_Volume_Scale = false,
});
Console.WriteLine($"Added plan {planId}");
}
This only reads data. You should see each plan with its plan_Start and plan_End.
foreach (var plan in (await api.GetAsync(plansPath)).Data?.AsArray() ?? new JsonArray())
Console.WriteLine($"Enrolled plan {plan!["plan_ID"]} {DatePart(plan["plan_Start"])} to {DatePart(plan["plan_End"])}");
GET /id-card-request-types and POST /id-card-requests
An ID card request records that a card should be produced for a member. Request types are configured per database, so read them first and use the right request_Type.
{
"idCardRequest_Key": 0,
"renewal": false,
"subscriber_ID": "DOCS-0001",
"member_Seq": "01",
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"request_Type": "REQUEST_TYPE_FROM_LOOKUP",
"request_Status": "REQ",
"request_Date": "2030-01-02T00:00:00",
"as_Of_Date": "2030-01-01T00:00:00",
"request_Quantity": 1,
"physical_Card_Requested": true
}idCardRequest_Keyandrenewalare required. Send0for the key; the database assigns it.request_Statustakes a value fromGET /id-card-request-statuses:REQ(Requested),COMPLETE, orCANCEL.as_Of_Dateis the date the card is effective from.issuance_Typeis left out. Which values your card vendor expects is set up per client; ask VBA before you send it.
var requestTypes = (await api.GetAsync("/id-card-request-types")).Data!.AsArray();
var card = (await api.PostAsync("/id-card-requests", new
{
idCardRequest_Key = 0, // 0 on create; VBAPI assigns the key
renewal = false,
subscriber_ID = SubscriberId,
member_Seq = insuredSeq,
group_ID = GroupId,
division_ID = DivisionId,
request_Type = requestTypes[0]!["request_Type"]!.GetValue<string>(),
request_Status = "REQ",
request_Date = $"{DateTime.Today:yyyy-MM-dd}T00:00:00",
as_Of_Date = $"{startDate}T00:00:00",
request_Quantity = 1,
physical_Card_Requested = true,
})).Data!;
var cardKey = card["idCardRequest_Key"]!.GetValue<int>();
Console.WriteLine($"ID card request {cardKey}");
Save from the response: idCardRequest_Key (step 10).
GET /id-card-requests/{iDCardRequestKey}
Read the request back to confirm it was saved. request_Status changes as the request is handled, for example to COMPLETE.
var savedCard = (await api.GetAsync($"/id-card-requests/{cardKey}")).Data!;
Console.WriteLine($"ID card request {savedCard["idCardRequest_Key"]} status: {savedCard["request_Status"]}");
Example response (illustrative; field values are fake):
{
"data": {
"idCardRequest_Key": 5001,
"subscriber_ID": "DOCS-0001",
"member_Seq": "01",
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"request_Type": "NEWENROLL",
"request_Status": "REQ",
"request_Date": "2030-01-02T00:00:00",
"as_Of_Date": "2030-01-01T00:00:00",
"request_Quantity": 1,
"renewal": false,
"physical_Card_Requested": true
},
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Run steps 6, 8, and 10 again; they only read data. The family should have one enrollment for the contract dates, the elected plans on it, and an ID card request with status REQ. GET /subscribers/{subscriberID}/list-all-enrollments gives a single view of every enrollment the subscriber has.
For a census file, load subscribers and members with the batch operations, then run steps 5 to 10 for each family.
PUT /subscribers-batchtakes an array of subscribers.PUT /subscribers/{subscriberID}/members-batchtakes one family's members.
Both create rows that do not exist and update rows that do, and return 207 Multi-Status. The data array has one item per row, with its own status (201 created, 200 updated, or an error status), a body message, and an id list of key and value pairs that identifies the row. One bad row does not stop the others, so always read every item.
Send member_Seq in the batch. The batch decides between create and update by subscriber_ID plus member_Seq, so without it every run adds new members. An update replaces the stored row with what you send, so include every field you want to keep.
The sample reads census-sample.csv, whose relationship column holds insured, spouse, or a code from GET /relationships:
subscriber_id,member_seq,relationship,first_name,last_name,address,city,state,zip_code
DOCS-0101,01,insured,Sample,Member,123 Example St,Exampleville,TX,00000
DOCS-0101,02,spouse,Sample Spouse,Member,123 Example St,Exampleville,TX,00000
DOCS-0102,01,insured,Test,Person,123 Example St,Exampleville,TX,00000Load the subscribers and collect the failures:
var families = ReadCsv(CensusFile()).GroupBy(row => row["subscriber_id"]).ToList();
var subscribers = families.Select(family =>
{
var insured = family.First(r => r["relationship"] == "insured");
return new
{
subscriber_ID = family.Key,
first_Name = insured["first_name"],
last_Name = insured["last_name"],
address = insured["address"],
city = insured["city"],
state = insured["state"],
zip_Code = insured["zip_code"],
};
}).ToList();
var response = await api.PutAsync("/subscribers-batch", subscribers); // 207 Multi-Status
var failedIds = new HashSet<string?>();
foreach (var item in response.Data?.AsArray() ?? new JsonArray())
{
if (item!["status"]!.GetValue<int>() < 400)
continue;
var key = Key(item);
failedIds.Add(key.GetValueOrDefault("Subscriber_ID"));
Console.WriteLine($"Subscriber failed: {key.GetValueOrDefault("Subscriber_ID")} {item["status"]} {item["body"]}");
}
Load each family's members, skipping families whose subscriber failed:
foreach (var family in families)
{
if (failedIds.Contains(family.Key))
continue; // fix the subscriber row first, then re-run; the batch updates rows that exist
var members = family.Select(row => new
{
subscriber_ID = family.Key,
member_Seq = row["member_seq"], // send it so a re-run updates instead of adding a member
first_Name = row["first_name"],
last_Name = row["last_name"],
relationship = codes.GetValueOrDefault(row["relationship"], row["relationship"]),
adult_Dependent = false,
continue_Coverage = false,
initial_Volume_Salary_Pct = false,
other_Insurance = false,
smoker = false,
use_Member_Plan_Year = false,
}).ToList();
var memberResponse = await api.PutAsync($"/subscribers/{family.Key}/members-batch", members);
foreach (var item in memberResponse.Data?.AsArray() ?? new JsonArray())
Console.WriteLine($"{family.Key} {Key(item!).GetValueOrDefault("Member_Seq")} {item!["status"]} {item["body"]}"); // 201 Created, 200 Updated
}
Example 207 response (illustrative; field values are fake):
{
"data": [
{ "id": [{ "key": "Subscriber_ID", "value": "DOCS-0101" }], "status": 201, "body": "Created" },
{ "id": [{ "key": "Subscriber_ID", "value": "DOCS-0102" }], "status": 400, "body": "A validation message for this row" }
],
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Keep batches to a size your system can retry, and stay within the rate limits when you loop over families.
- The main sample checks before each create: it skips the subscriber if
GET /subscribers/{subscriberID}finds it, skips members whose relationship is already in the family, skipsadd-enrollmentif the enrollment exists, and skips plans already on it. add-enrollmentcreates the enrollment row keyed by subscriber, group, division, contract start, and start date, so repeating it with the same dates fails rather than creating a duplicate.- Each ID card request is a new record. Running step 9 twice requests two cards.
- The census batches are upserts: re-running them updates the same rows, as long as
member_Seqis in the file.
To remove a test family:
- Delete the ID card request with
DELETE /id-card-requests/{iDCardRequestKey}. - Delete the subscriber with
DELETE /subscribers/{subscriberID}. The database removes the subscriber's enrollments and members with it, and refuses the delete if the subscriber has claims, authorizations, or accumulators.
Deletes return 204. To remove only one enrollment or one dependent, use DELETE /subscribers/{subscriberID}/enrollments/{groupID}/{divisionID}/{groupCoverageStart}/{startDate} or DELETE /subscribers/{subscriberID}/members/{memberSeq}. Deleting the insured member this way returns 400; delete the subscriber instead. For a real member who is leaving, disenroll instead of deleting; see Disenroll a member.
| Symptom | Likely cause | Fix |
|---|---|---|
400 "No member exists for the Subscriber ID" on step 5 | Members were not created | Run step 4 first |
400 "The Group Contract does not exsit" on step 5 | group_Coverage_Start does not match a contract's coverage_Start | Use the exact date from step 1 |
400 "The End Date must be populated" on step 5 | end_Date was left out | Send end_Date, usually the contract's coverage_End |
400 on a member, naming subscriber_ID | The body subscriber_ID does not match the path | Send the same ID in both |
422 "Maximum of 99 members exceeded" | The family already has member_Seq 99 | Check for duplicate members |
Batch item with status 400 or 500 | That row failed validation | Read body, fix the row, and re-run; the other rows are already saved |
403 with {"message":"Forbidden"} on every call | The x-api-key header is missing or wrong | See Errors and troubleshooting |
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.
- Member-level plans: when the group contract's
benefit_At_Memberis"1", plans are chosen per member. Read them withGET /subscribers/{subscriberID}/members/{memberSeq}/groups/{groupID}/divisions/{divisionID}/enrollments. - Producing the cards:
POST /idcardrequest-process-automationtakes a list ofidCardRequest_Keyvalues and runs the process automation your database has configured for ID card requests. Ask VBA how card production is set up for you.
The samples read your credentials from environment variables, so nothing secret is written into code. Set them in the terminal you run the samples from:
$env:VBAPI_BASE_URL = "https://vbapi.vbasoftware.com/vbasoftware"
$env:VBAPI_API_KEY = "your-api-key"
$env:VBAPI_CLIENT_ID = "your-client-id"
$env:VBAPI_CLIENT_CODE = "your-client-code"
$env:VBAPI_DATABASE = "YOUR_DATABASE"
$env:VBAPI_USERNAME = "your-api-username"
$env:VBAPI_PASSWORD = "your-api-password"
$env:VBAPI_USER_AGENT = "YourCompany-Integration/1.0"In production code, load these from your secrets manager instead.
The full runnable samples are in the docs repository under _samples/playbooks/enroll-employees/: EnrollEmployeesPlaybook.cs, enroll_employees.py, and enrollEmployees.ts for one family, and EnrollEmployeesCensusPlaybook.cs, enroll_census.py, enrollCensus.ts, and census-sample.csv for the census variation. See _samples/playbooks/README.md for how to run them.
- Concepts: Data model, IDs and conventions, Requests and responses
- Previous playbook: Onboard a new employer group
- Next playbooks: Member self-service, Bill premiums and apply payments
- Ask the AI: "Using the Enroll employees playbook, write a Python function that loads a census CSV with the batch endpoints and lists the rows that failed."