Product that suits modern B2B Tech companies

Book Demo
B
Book demo call-to-action illustration
BACK
B

How to make Workday SOAP API Get_Workers requests in Python

Platform APIs
September 22, 2026
Summarise the blog with AI
Open in ChatGPT
Ask questions about this page
Open in Claude
Ask questions about this page

Key takeaways

  • A Get_Workers call is a hand-built SOAP envelope with a WS-Security UsernameToken for your Integration System User. There is no bearer token or JSON.
  • Response_Group decides which sections come back, and Response_Filter decides paging. Neither has a useful default.
  • Keep As_Of_Entry_DateTime fixed for the whole pull, or workers can be skipped or duplicated across pages.
  • Put request elements in the order the WSDL defines. Workday validates it.
  • A 200 with blank fields is almost always a missing domain grant on the ISU, not a bug in your code.

Ask Workday's Get_Workers operation for a list of employees and it hands back exactly what you asked for: an XML message inside another XML message, secured by a header you build yourself.

A REST integration takes an afternoon: OAuth, a GET request, parse the JSON. Get_Workers takes a WS-Security header, a Response_Group nobody sets for you, and a paging convention that silently drops or duplicates workers if you get it wrong.

The envelope underneath hasn't moved much. A SOAP message is an XML document with a mandatory Envelope, an optional Header and a mandatory Body, a structure the W3C published as a Note in May 2000. Workday Web Services (WWS) still speaks that dialect, and it's still the broadest system-to-system interface for worker data, alongside Workday's narrower REST API and RaaS reports.

This is the complete path, with working code: authenticate, build and page the request, parse the response with lxml, and diagnose the failure most references skip, a 200 that comes back empty.

What a Get_Workers call is

Get_Workers is a WWS operation in the Human_Resources service. You POST a SOAP-wrapped XML request and get back a SOAP-wrapped XML response describing one or more workers. The Envelope carries a Header, where your credentials live, and a Body, where the request sits.

The closest REST analogy is a POST whose entire payload, including auth, is XML instead of a JSON body plus an Authorization header. If SOAP is new to you, read SOAP for REST developers first; it covers envelopes, namespaces and WSDLs.

What Get_Workers won't do is guess. It returns the sections you explicitly request and nothing more, so the request has to be deliberate about what it's asking for.

Setup: dependencies, credentials and your WWS version

  1. Install the dependencies. requests sends the POST, lxml parses the XML with XPath, and python-dotenv keeps credentials out of source control.
  2. Get an Integration System User (ISU). Ask your Workday admin for a dedicated ISU and its security group, not a personal login. The group's domain permissions decide which fields Get_Workers can return. If you're the admin, our ISU provisioning guide walks through it.
  3. Find your endpoint and version. Your tenant's WSDL tells you the host, tenant name and supported WWS versions. Pin one and treat it as configuration.
  4. Store credentials outside the code, in environment variables loaded from a .env file.
Shell
pip install requests lxml python-dotenv
.env
# .env  (never commit this file)
WORKDAY_ENDPOINT=https://{host}/ccx/service/{tenant}/Human_Resources/v{version}
WORKDAY_USERNAME=ISU_USERNAME@TENANT
WORKDAY_PASSWORD=your-isu-password
WORKDAY_WWS_VERSION=v{version}

Don't hardcode a version as "current." Workday ships new WWS versions on a regular cycle. A pinned version keeps working for a long time, but new fields and operations only arrive in newer ones, so revisit it on purpose.

Authenticating with a WS-Security UsernameToken

Get_Workers doesn't take an API key. It takes a UsernameToken inside a Security header inside the SOAP Header, defined by the OASIS UsernameToken Profile. The username is your ISU in the form username@tenant, and the password travels as plain text, so the request must go over HTTPS.

One detail most examples miss: escape the credentials. A password containing an ampersand or a less-than sign breaks the XML, and Workday answers with a parse fault that looks like an auth problem.

Python · Envelope with WS-Security UsernameToken
from string import Template
from xml.sax.saxutils import escape

ENVELOPE = Template("""

  
    
      
        $username
        $password
      
    
  
  
    $body
  
""")

def build_envelope(username, password, body):
    # Escape credentials: a password containing & or < would otherwise break the XML.
    return ENVELOPE.substitute(username=escape(username), password=escape(password), body=body)

If authentication is wrong, Workday returns a SOAP fault before it evaluates anything else, which is the first thing to check when a request fails outright.

Building the request: Response_Group and paging

The request body needs three things: what to search for, how to page, and what data to include. Order matters. The WSDL defines the sequence as Request_References or Request_Criteria, then Response_Filter, then Response_Group, and Workday rejects a request that puts them out of order.

  • Request_Criteria narrows the search, for example Exclude_Inactive_Workers.
  • Response_Filter sets As_Of_Entry_DateTime, Page and Count, in that order.
  • Response_Group turns on each data section you need.
Response_Group flagWhat it adds
Include_ReferenceWorker IDs (WID, Employee_ID). Cheap, and you almost always want it.
Include_Personal_InformationLegal and preferred names, contact data, and personal details
Include_Employment_InformationStatus, hire and termination dates, and position and job data
Include_CompensationCompensation plans and amounts
Include_OrganizationsSupervisory, cost center, company and other organization assignments
Include_RolesRoles the worker holds, such as manager or HR partner
Every flag you enable adds payload to every page. Each one also needs the matching domain grant on the ISU, or the section comes back empty.

The fetch function below builds the body, posts it, and reads SOAP faults properly. Workday returns faults as HTTP 500 with an XML body, so a plain raise_for_status() throws away the one message that tells you what went wrong.

Python · Request body and fetch with SOAP fault handling
import requests
from lxml import etree

NS = {"bsvc": "urn:com.workday/bsvc",
      "env": "http://schemas.xmlsoap.org/soap/envelope/"}

GET_WORKERS_BODY = Template("""
  
    true
  
  
    $as_of
    $page
    $count
  
  
    true
    true
    true
  
""")

class WorkdaySoapFault(Exception):
    pass

def fetch_page(session, endpoint, username, password, wws_version, page, count, as_of):
    body = GET_WORKERS_BODY.substitute(wws_version=wws_version, page=page, count=count, as_of=as_of)
    response = session.post(
        endpoint,
        data=build_envelope(username, password, body).encode("utf-8"),
        headers={"Content-Type": "text/xml; charset=utf-8"},
        timeout=60,
    )
    # Workday returns SOAP faults (bad credentials, invalid request) as HTTP 500 with an XML body.
    # Read the fault message before raise_for_status() throws it away.
    if response.status_code >= 400 and b"Fault" in response.content:
        tree = etree.fromstring(response.content)
        message = tree.xpath("string(//env:Fault/faultstring)", namespaces=NS)
        raise WorkdaySoapFault(message or response.text[:500])
    response.raise_for_status()
    return response.content

Paging without gaps or duplicates

Generate As_Of_Entry_DateTime once, at the start of the pull, and reuse it for every page. If you regenerate it per page, a worker hired between page 1 and page 2 shifts where every later worker lands, and you skip or duplicate records with no error to tell you.

Python · Page through all workers
from datetime import datetime, timezone

def get_total_pages(xml_bytes):
    tree = etree.fromstring(xml_bytes)
    total = tree.xpath("//bsvc:Response_Results/bsvc:Total_Pages/text()", namespaces=NS)
    return int(total[0]) if total else 1

def fetch_all_workers(endpoint, username, password, wws_version, count=100):
    # One snapshot time for the whole pull, so pages don't shift under you.
    as_of = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    session = requests.Session()
    page, pages = 1, []
    while True:
        raw = fetch_page(session, endpoint, username, password, wws_version, page, count, as_of)
        pages.append(raw)
        if page >= get_total_pages(raw):
            break
        page += 1
    return pages

Count is commonly capped at 999 per page, and 100 is a sensible default. Confirm against your tenant. The same snapshot-plus-cursor idea applies to any offset-paginated API, as our offset pagination guide explains.

Parsing the response with lxml

Each page is a SOAP envelope with a Response_Results block (Total_Results, Total_Pages) and a Response_Data block with one Worker element per employee. XPath is the right tool for pulling fields out of a document you didn't design.

Python · Parse workers with lxml
def first(node, path):
    found = node.xpath(path, namespaces=NS)
    return found[0] if found else None

def parse_workers(xml_bytes):
    tree = etree.fromstring(xml_bytes)
    records = []
    for worker in tree.xpath("//bsvc:Response_Data/bsvc:Worker", namespaces=NS):
        records.append({
            "wid": first(worker, "./bsvc:Worker_Reference/bsvc:ID[@bsvc:type='WID']/text()"),
            "employee_id": first(worker, "./bsvc:Worker_Reference/bsvc:ID[@bsvc:type='Employee_ID']/text()"),
            # Formatted_Name is an attribute on Name_Detail_Data, not a child element.
            "name": first(worker, ".//bsvc:Legal_Name_Data/bsvc:Name_Detail_Data/@bsvc:Formatted_Name"),
            "email": first(worker, ".//bsvc:Email_Address_Data/bsvc:Email_Address/text()"),
            "hire_date": first(worker, ".//bsvc:Worker_Status_Data/bsvc:Hire_Date/text()"),
        })
    return records

Two things trip people up here:

  • Namespaces. Every element is in the urn:com.workday/bsvc namespace, whatever prefix the response uses (often wd). An XPath without the namespace map silently matches nothing.
  • Attributes versus elements. The formatted name lives in a Formatted_Name attribute on Name_Detail_Data, not in a child element. Select it with @, or you'll get None for every worker.
Python · Run it
import os
from dotenv import load_dotenv

if __name__ == "__main__":
    load_dotenv()
    pages = fetch_all_workers(
        endpoint=os.environ["WORKDAY_ENDPOINT"],
        username=os.environ["WORKDAY_USERNAME"],
        password=os.environ["WORKDAY_PASSWORD"],
        wws_version=os.environ["WORKDAY_WWS_VERSION"],
    )
    workers = [w for page in pages for w in parse_workers(page)]
    print(f"Fetched {len(workers)} workers")
    empty = [w["employee_id"] for w in workers if not w["name"]]
    if empty:
        # A 200 with blank fields usually means a missing domain grant, not a code bug.
        print(f"{len(empty)} workers came back without a name: check the ISU's domain permissions")

When it breaks: 200 with no data, faults and version drift

The failure that catches people is the one where nothing looks wrong. The request returns 200, the envelope is well formed, and workers come back with some fields populated and others blank.

That's Workday's security doing its job. Every section Get_Workers can return sits behind a security domain, and the ISU's security group has to be granted that domain, independent of whether your envelope and Response_Group are correct. A UsernameToken that authenticates proves the ISU exists; it proves nothing about what it can see. The fix isn't in your code. Bindbee's Workday setup guide lists the domains a typical HR, payroll and benefits read needs, which makes a good checklist for the admin.

SymptomUsual causeWhere to fix it
HTTP 500 with a SOAP fault mentioning authenticationWrong ISU username format (use username@tenant), wrong password, or an expired ISU passwordCredentials, and the ISU's password rules
HTTP 500 with a validation errorElements in the wrong order, or an element that doesn't exist in your pinned versionRequest body, against your WSDL
HTTP 200, workers returned but fields blankThe ISU's security group lacks the domain behind that sectionWorkday admin: domain permissions
HTTP 200, no workers at allMissing Worker Data: Public Worker Reports, or filters too narrowDomain permissions first, then Request_Criteria
Duplicates or gaps across pagesA different As_Of_Entry_DateTime on each pageGenerate the timestamp once per pull
Your XPath finds nothing on a full responseMissing namespace prefix, or selecting an attribute as if it were an elementParser

Version drift causes a quieter version of the same problem. Fields and defaults can change between WWS versions, so a script that worked at launch can return something different after an upgrade. Keep the version in configuration and change it deliberately.

One tenant is a script. Ten tenants are a job.

Diagnosing a missing domain or a version mismatch for one tenant takes an afternoon. Doing it for ten, each with its own security configuration and upgrade timing, turns this script into a standing maintenance commitment.

That's where a unified API earns its place. Bindbee connects to Workday and 67+ other HRIS, payroll, ATS and benefits systems through one API, with 40+ unified data models, so versioning, parsing and field mapping happen once per system instead of once per customer.

  • It authenticates against Workday the same way this guide does, as an ISU your customer creates, so every call stays inside Workday's security.
  • It reads and writes through the same API.
  • It syncs every 24 hours by default, adjustable per connection, and sends a webhook when a sync finishes or fails, so a permissions change surfaces quickly instead of as silently blank fields.

None of that changes the case for building it yourself if you're pulling from one tenant. The procedure above is the right amount of engineering for that. The maintenance argument shows up at the second tenant. If that's where you are, look at Bindbee's Workday connector before you build a second copy of everything above.

FAQ

How do I authenticate a Workday Get_Workers SOAP request?

Put a WS-Security UsernameToken in the SOAP header with the Integration System User's name in the form username@tenant and its password, and send the request over HTTPS. Workday also accepts an OAuth 2.0 bearer token from a registered API client instead of the password.

Why does Get_Workers return HTTP 200 with missing fields?

Almost always because the ISU's security group doesn't have the domain that secures those fields. Authentication proves the ISU is valid, not what it can see. Ask a Workday admin to check the group's domain permissions for the Response_Group sections you requested.

How do I page through Get_Workers results?

Set Page and Count in Response_Filter, read Total_Pages from Response_Results, and loop. Keep As_Of_Entry_DateTime fixed for the whole pull so workers added mid-pull don't shift between pages. Count is commonly capped at 999 per page.

Which WWS version should I use?

Pin the version your tenant supports, taken from its WSDL, and treat it as configuration rather than a constant. New fields and operations only arrive in newer versions, so plan upgrades deliberately.

Is Workday SOAP still used?

Yes. Workday Web Services, the SOAP layer, is still the broadest system-to-system interface for worker data. Workday also has a REST API with narrower coverage, and RaaS for read-only report extracts.

Kunal Tyagi
CTO
Bindbee
VIEW AUTHOR
BLOG_

Related blogs