This playbook is for developers building the backend of a member portal or mobile app on VBAPI. It shows the calls that answer a member's everyday questions: what plan am I on, how much of my deductible have I met, what happened to my claims, and how do I get a new ID card.
| Who this is for | Developers of member portals, mobile apps, and member service tools |
| Time to run | About 15 minutes |
| Writes data | Yes, one step: the replacement ID card request. Every other step only reads data. |
| Operations used | 10: GET /subscribers/{subscriberID}/members/{memberSeq}, GET /subscribers/{subscriberID}/enrollments, GET /subscribers/{subscriberID}/enrollments/{groupID}/{divisionID}/{groupCoverageStart}/{startDate}/plans, GET /subscribers/{subscriberID}/members/{memberSeq}/list-plan-year, GET /subscribers/{subscriberID}/members/{memberSeq}/list-accum-detail, GET /subscribers/{subscriberID}/members/{memberSeq}/list-member-claim, GET /claims/{claimNumber}/eob, GET /id-card-request-types, POST /id-card-requests, GET /id-card-requests/{iDCardRequestKey} |
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.
VBAPI authenticates your integration, not your members. The API user is a service account that can read every member in the database, and VBAPI returns whatever subscriberID and memberSeq you ask for. Your app must:
- sign in its own members with your own identity provider
- map each signed-in member to their
subscriber_IDandmember_Seqon your server, and never take those values from the browser or app - call VBAPI only from your server, so the API key, password, and ID token never reach a member's device
- show a member only their own data, or their family's if your policy allows it
Data masking settings in VBASoftware can hide some fields (for example SSN) in responses; do not rely on masking for access control.
- 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 member is enrolled in a plan. The samples use
DOCS-0001, member01, from Enroll employees and dependents. - The API user can read members, enrollments, accumulators, and claims, and create ID card requests.
GET /subscribers/{subscriberID}/members/{memberSeq}
Load the signed-in member's name and details for the app's profile screen.
var member = (await api.GetAsync(memberPath)).Data!;
Console.WriteLine($"Member: {member["first_Name"]} {member["last_Name"]} relationship: {member["relationship"]}");
GET /subscribers/{subscriberID}/enrollments and GET /subscribers/{subscriberID}/enrollments/{groupID}/{divisionID}/{groupCoverageStart}/{startDate}/plans
Enrollments belong to the family (the subscriber). Pick the enrollment whose start_Date to end_Date covers today, then read its plans. The sample falls back to the latest enrollment, which helps with test data dated in the future.
var enrollments = new List<JsonNode>();
await foreach (var e in api.GetAllAsync($"/subscribers/{SubscriberId}/enrollments"))
enrollments.Add(e!);
enrollments = enrollments.OrderBy(e => DatePart(e["start_Date"]), StringComparer.Ordinal).ToList();
var enrollment = enrollments.FirstOrDefault(e => Covers(e, "start_Date", "end_Date", today)) ?? enrollments[^1];
var groupId = enrollment["group_ID"]!.GetValue<string>();
var divisionId = enrollment["division_ID"]!.GetValue<string>();
var plans = (await api.GetAsync(
$"/subscribers/{SubscriberId}/enrollments/{groupId}/{divisionId}/{DatePart(enrollment["group_Coverage_Start"])}/{DatePart(enrollment["start_Date"])}/plans")).Data!.AsArray();
var planId = plans[0]!["plan_ID"]!.GetValue<string>();
Console.WriteLine($"Group {groupId} plan {planId}");
Save from the response: group_ID, division_ID, and plan_ID (steps 4 and 7).
GET /subscribers/{subscriberID}/members/{memberSeq}/list-plan-year
Accumulators (deductible and out-of-pocket totals) reset each plan year. This lists the plan years that apply to the member, from the group's plan years and the member's own plan year if they have one. Pick the one whose from_Date to thru_Date covers today.
var planYears = (await api.GetAsync($"{memberPath}/list-plan-year")).Data!.AsArray()
.Select(y => y!).OrderBy(y => DatePart(y["from_Date"]), StringComparer.Ordinal).ToList();
var planYear = (planYears.FirstOrDefault(y => Covers(y, "from_Date", "thru_Date", today)) ?? planYears[^1])["plan_Year"]!.GetValue<string>();
Console.WriteLine($"Plan year {planYear}");
Example response (illustrative; field values are fake):
{
"data": [
{
"group_ID": "DOCS-GRP1",
"division_ID": "DOCS-DIV1",
"plan_Year": "2030",
"from_Date": "2030-01-01T00:00:00",
"thru_Date": "2030-12-31T00:00:00",
"qtr4_Accum": false,
"qtr4_Deductible": false,
"qtr4_Member": false,
"qtr4_Out_Of_Pocket": false,
"qtr4_Subscriber": false
}
],
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}Save from the response: plan_Year (step 4).
GET /subscribers/{subscriberID}/members/{memberSeq}/list-accum-detail
Pass groupId, planId, and planYear as query parameters. Each row has the limits and the amounts used so far, for the member, the family (subscriber), and by network.
var query = $"groupId={Uri.EscapeDataString(groupId)}&planId={Uri.EscapeDataString(planId)}&planYear={Uri.EscapeDataString(planYear)}";
var accumulators = (await api.GetAsync($"{memberPath}/list-accum-detail?{query}")).Data!.AsArray();
foreach (var row in accumulators)
{
var oopMax = Amount(row!, "ooP_Max_Mem");
var oopUsed = Amount(row!, "out_Of_Pocket");
Console.WriteLine($"Deductible: {Amount(row!, "deductible_Used"):F2} of {Amount(row!, "member_Deductible"):F2} met");
Console.WriteLine($"Out of pocket: {oopUsed:F2} of {oopMax:F2}, {Math.Max(oopMax - oopUsed, 0):F2} to go");
}
These fields answer the two common questions. The sample uses the member-level, all-network fields:
| Question | Limit | Used so far |
|---|---|---|
| How much of my deductible have I met? | member_Deductible | deductible_Used |
| How much of my family's deductible is met? | subscriber_Deductible | fam_Deductible_Used |
| How much until my out-of-pocket maximum? | ooP_Max_Mem | out_Of_Pocket |
| How much until my family's out-of-pocket maximum? | ooP_Max_Sub | fam_Out_Of_Pocket |
Fields with PPO or Net in the name are the in-network figures, and OON the out-of-network figures, for plans that track them separately; ooP_Calc_Option describes how the plan combines them. Have your benefits team confirm which fields match your plan designs before you show them to members.
Example response (illustrative; field values are fake):
{
"data": [
{
"plan_ID": "DOCS-PLAN1",
"member_Deductible": 1500.0,
"deductible_Used": 400.0,
"subscriber_Deductible": 3000.0,
"fam_Deductible_Used": 650.0,
"ooP_Max_Mem": 5000.0,
"out_Of_Pocket": 820.0,
"ooP_Max_Sub": 10000.0,
"fam_Out_Of_Pocket": 1100.0
}
],
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}GET /subscribers/{subscriberID}/members/{memberSeq}/list-member-claim
This returns every claim for the member, newest service date first, in one response. It takes no paging or filter parameters, so page and filter in your app (for example, the last 12 months, 10 per screen).
var claims = (await api.GetAsync($"{memberPath}/list-member-claim")).Data?.AsArray()
.Select(c => c!).ToList() ?? new List<JsonNode>(); // newest service date first
foreach (var claim in claims.Take(10)) // the endpoint returns every claim; page them in your app
{
var state = claim["inBatch"]?.GetValue<int>() == 1 ? "in process" : claim["claim_Status"]?.ToString();
Console.WriteLine($"{DatePart(claim["service_Date"])} {claim["provider_Name"]} {state} billed {claim["billedAmount"]} paid {claim["paidAmount"]}");
}
Fields worth showing a member:
| Field | Meaning |
|---|---|
service_Date, service_Thru | Dates of service |
provider_Name | Who provided the care |
inBatch | 1 for a claim still being entered or adjudicated (in a claim batch), 0 for a processed claim |
claim_Status | The claim's status code. Codes are configured per database; read their descriptions with GET /claim-statuses and agree member-friendly wording with your claims team |
billedAmount, paidAmount | What the provider billed and what the plan paid |
check_Number, check_Date | Payment details, once paid |
claim_Number | The processed claim's number, used for the EOB in step 6 |
Example response (illustrative; field values are fake):
{
"data": [
{
"inBatch": 0,
"claim_Number": 900001,
"batch_Number": 7001,
"batch_Claim": 1,
"subscriber_ID": "DOCS-0001",
"member_Seq": "01",
"provider_Name": "Example Clinic",
"service_Date": "2030-03-04T00:00:00",
"service_Thru": "2030-03-04T00:00:00",
"claim_Status": "PAID",
"billedAmount": 250.0,
"paidAmount": 180.0,
"check_Number": "No Check"
}
],
"error": null,
"debug": { "activityID": "00000000-0000-0000-0000-000000000000" }
}For a processed claim, this returns the member's Explanation of Benefits. If your company uses Zelis RedCard EOBs, VBAPI tries that first; otherwise, or if that fails, VBASoftware generates the EOB. report_Bytes holds the document (generally a PDF) as base64, and isVBA_EOB says which source produced it. Stream it to the signed-in member; do not store or log it.
var processed = claims.FirstOrDefault(c => c["inBatch"]?.GetValue<int>() == 0 && c["claim_Number"] is not null);
if (processed is not null)
{
var claimNumber = processed["claim_Number"]!.GetValue<int>();
var eob = (await api.GetAsync($"/claims/{claimNumber}/eob")).Data!;
var reportBytes = eob["report_Bytes"]?.GetValue<string>();
var pdf = reportBytes is null ? Array.Empty<byte>() : Convert.FromBase64String(reportBytes);
// Stream the PDF to the signed-in member. Do not store or log it: it contains PHI.
Console.WriteLine($"EOB for claim {claimNumber} {pdf.Length} bytes");
}
Check that the EOB's subscriber_ID and member_Seq match the signed-in member before you return it.
GET /id-card-request-types and POST /id-card-requests
This is the only step that writes data. The body is the same as in the enrollment playbook, with the member's current group and division and the request type your database uses for replacements:
{
"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-03-10T00:00:00",
"request_Quantity": 1,
"physical_Card_Requested": true
}The sample takes the first request type for simplicity. In your app, choose the replacement type by its description.
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 = MemberSeq,
group_ID = groupId,
division_ID = divisionId,
request_Type = requestTypes[0]!["request_Type"]!.GetValue<string>(),
request_Status = "REQ",
request_Date = $"{today}T00:00:00",
request_Quantity = 1,
physical_Card_Requested = true,
})).Data!;
var cardKey = card["idCardRequest_Key"]!.GetValue<int>();
Console.WriteLine($"Replacement ID card requested: {cardKey}");
Save from the response: idCardRequest_Key (step 8). Each call creates a new request, so stop members from submitting twice, for example by checking GET /id-card-requests on your server for a recent open request.
GET /id-card-requests/{iDCardRequestKey}
Show the member the request's request_Status: REQ (Requested), COMPLETE, or CANCEL, from GET /id-card-request-statuses.
var savedCard = (await api.GetAsync($"/id-card-requests/{cardKey}")).Data!;
Console.WriteLine($"ID card request {savedCard["idCardRequest_Key"]} status: {savedCard["request_Status"]}");
Run the sample against a test member whose plan, accumulators, and claims you know. The deductible and out-of-pocket figures should match what VBASoftware shows for the same plan year, and the claim list should match the member's claims. Step 8 confirms the ID card request was saved.
A member screen can need five or more calls, and every call counts toward your API key's rate limit of 25 requests per second.
- Cache per member on your server, never on the device. Member details, enrollments, plans, and plan years rarely change; caching them for the session, or for several hours, is reasonable.
- Accumulators and claims change when claims are adjudicated. Cache them briefly (minutes, not days), refresh them when the member opens the screen, and show an "as of" time.
- Request types and statuses are lookups; cache them for a day.
- Load the screens a member opens, not everything at sign-in.
A replacement request can be cancelled by updating it with request_Status CANCEL and a cancellation_Reason through PUT /id-card-requests/{iDCardRequestKey}, or removed with DELETE /id-card-requests/{iDCardRequestKey} (204). The other steps only read data.
| Symptom | Likely cause | Fix |
|---|---|---|
404 on step 1 | Wrong subscriberID or memberSeq; memberSeq is two characters, for example 01 | Check your mapping from the signed-in member |
| Empty accumulator list | groupId, planId, or planYear does not match the member's enrollment | Use the values from steps 2 and 3 |
404 or 500 on the EOB | The claim number is not a processed claim, or the EOB could not be produced | Only request EOBs for claims with inBatch 0; contact support with the activityID if it persists |
429 during busy periods | Too many calls per second for the API key | Cache as above; the sample helpers retry with backoff |
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.
- One benefit's accumulators:
GET /subscribers/{subscriberID}/members/{memberSeq}/list-accum-benefit-detailadds a benefit code. The query parameter is spelledbeneftiCodein the API; send it with that spelling.GET /subscribers/{subscriberID}/members/{memberSeq}/list-accum-all-benefit-detailreturns every benefit the member has accumulators for. - Everything in one call:
POST /enrollment-benefit-inquiryreturns eligibility, benefits, and accumulators together, which suits a benefits summary screen. - Address changes: read and update the member's address with
GET /subscribers/{subscriberID}/members/{memberSeq}/addressesandPUT /subscribers/{subscriberID}/members/{memberSeq}/addresses. This writes data; confirm the change with the member before you send it.
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/member-self-service/: MemberSelfServicePlaybook.cs, member_self_service.py, and memberSelfService.ts. See _samples/playbooks/README.md for how to run them.
- Concepts: Authentication and tokens, Rate limits and performance, Data model
- Previous playbook: Enroll employees and dependents
- Related playbook: Enter claims and adjudicate them
- Ask the AI: "Using the Member self-service playbook, write a TypeScript function that returns a member's deductible and out-of-pocket progress for the current plan year."