Back to Journal
WorkdayBy Steve Farmer | | 22 min read

Workday SOAP API Extraction Guide: Pagination, Throttling, Retries and Restart Safety

How to page Get_Workers on Workday Web Services v47.0, retry throttled SOAP calls, and restart a failed extraction without losing or duplicating a worker, with a runnable synthetic example.

WorkdaySOAP APIsCustom ConnectorsFivetranRate LimitsData EngineeringPythonEnterprise Integration

TL;DR / Key Takeaways

  • Page Workday Web Services with Page and Count in Response_Filter, and pin As_Of_Entry_DateTime on every page. Workday's own reference says to, so the result set stays the same between requests.
  • A throttled WWS SOAP call comes back as HTTP 500, and so does every SOAP fault. Read the fault body before deciding to retry: validation and authentication faults fail fast, a bare 500 or a Server fault gets exponential backoff with jitter.
  • Restart safety is yours to build. Checkpoint after each committed page, resume only while Workday's 2-hour paging cache is likely still there, otherwise replay the window, and upsert by WID so a replay cannot duplicate a row.
  • Before you mark a window complete, check that the pages delivered exactly Total_Results distinct workers with no WID on two pages. The runnable example shows four broken extractions that the check catches.

A restart-safe Workday SOAP extraction pins one entry moment for the whole result set, commits a checkpoint with each page, retries only the failures that can succeed later, and reconciles every window by WID before moving on. This guide builds that for Get_Workers on Workday Web Services (WWS) v47.0, the current version as of October 11, 2026, and tests it against a synthetic service that fails on purpose. Every Workday behavior it relies on links to the Workday page it came from; everything Workday does not document is labeled as this example's own policy.

If you would rather have this built and supported against your tenant, that is our custom Fivetran SDK connector service.

What is documented and what is not

Nothing in this guide was run against a real Workday tenant. The request and response element names come from Workday's public v47.0 schema; the behavior of the service in the example is synthetic.

Claim Source Status
Page, Count (1 to 999, default 100) and As_Of_Entry_DateTime page the results Get_Workers v47.0 reference Documented by Workday
Total_Results, Total_Pages, Page_Results and Page describe the result set Same reference, Response_Results Documented by Workday
Throttled SOAP requests return HTTP 500; use exponential backoff Integrations, EIB, and Web Service Limits Documented by Workday
Paging cache kept 2 hours between requests and 30 minutes after the last page Same limits page Documented by Workday
Every SOAP fault uses HTTP 500; Client faults should not be resent unchanged SOAP 1.1, sections 4.4.1 and 6.2 W3C specification
A request-per-second or concurrency limit for WWS SOAP Not published on the limits page Unknown; do not assume one
Result order across pages, or after the paging cache expires Not documented Unknown; the example does not depend on it
Checkpoint shape, resume rule, window check, upsert by WID This example Application-level policy

Run the example

The example is standard-library Python 3.10 or newer. It has no credentials, makes no network calls, and contains no real people: every worker, WID and timestamp is generated.

mkdir workday-soap-extraction && cd workday-soap-extraction
base=https://itsmorethansoftware.com/examples/workday-soap-extraction
for f in README.md soap.py extract.py destination.py reconcile.py \
    synthetic_workday.py demo.py test_extraction.py test_negative_controls.py; do
  curl -fsSO "$base/$f"
done
python3 -m unittest -v
python3 demo.py
$ python3 -m unittest
.....................
----------------------------------------------------------------------
Ran 21 tests in 0.642s

OK

Extraction architecture

The extraction runs one window per scheduled run. The first run takes a full snapshot; every later run reads the transaction log from where the last one finished.

scheduler ──► extract.py ──► soap.py ──► Get_Workers (WWS v47.0)
                 │               │
                 │               └── classify faults: retry, or fail fast
                 │
                 ├── per page: rows + load log + checkpoint, one transaction
                 │                        │
                 │                        ▼
                 │              destination (table keyed by WID)
                 │
                 └── per window: pages and Total_Results check, then advance
Component Responsibility In the example
Request builder Version-scoped envelope, documented limits checked before sending soap.py
Fault classifier Retry or fail from the HTTP status and the SOAP fault body soap.py
Extractor Window, pinned entry moment, page loop, retries, per-page checks extract.py
Destination Rows upserted by WID, a load log, and the checkpoint, committed together destination.py (in memory)
Reconciliation Window check from the load log; destination check against a snapshot reconcile.py

Each window is defined by three timestamps that never change once the window starts:

Run Updated_From Updated_Through As_Of_Entry_DateTime
First (snapshot) not sent not sent now minus a 5-minute lag
Later (incremental) previous entry moment now minus a 5-minute lag same as Updated_Through

The lag keeps Updated_Through safely behind Workday's clock; Workday rejects a request where Updated Through is later than the current moment. Setting the entry moment equal to Updated_Through makes each window a consistent picture as of its end, and the next window starts exactly there. Workday describes the bounds as the earliest and latest moment data was entered, so a change at the boundary instant can be returned by both windows; upsert by WID absorbs that.

The request

This is the request soap.py builds for page 1 of the October 10 window in the example. Count is 2 only to keep the sample short; the extractor defaults to Workday's default of 100.

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:wd="urn:com.workday/bsvc">
  <soapenv:Header>
    <wsse:Security soapenv:mustUnderstand="1"
        xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
      <wsse:UsernameToken>
        <wsse:Username>ISU_USERNAME@TENANT_NAME</wsse:Username>
        <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">REDACTED</wsse:Password>
      </wsse:UsernameToken>
    </wsse:Security>
  </soapenv:Header>
  <soapenv:Body>
    <wd:Get_Workers_Request wd:version="v47.0">
      <wd:Request_Criteria>
        <wd:Transaction_Log_Criteria_Data>
          <wd:Transaction_Date_Range_Data>
            <wd:Updated_From>2026-10-09T12:00:00Z</wd:Updated_From>
            <wd:Updated_Through>2026-10-11T00:00:00Z</wd:Updated_Through>
          </wd:Transaction_Date_Range_Data>
        </wd:Transaction_Log_Criteria_Data>
      </wd:Request_Criteria>
      <wd:Response_Filter>
        <wd:As_Of_Entry_DateTime>2026-10-11T00:00:00Z</wd:As_Of_Entry_DateTime>
        <wd:Page>1</wd:Page>
        <wd:Count>2</wd:Count>
      </wd:Response_Filter>
      <wd:Response_Group>
        <wd:Include_Reference>true</wd:Include_Reference>
        <wd:Include_Personal_Information>false</wd:Include_Personal_Information>
        <wd:Include_Employment_Information>true</wd:Include_Employment_Information>
        <wd:Include_Compensation>false</wd:Include_Compensation>
        <wd:Include_Organizations>false</wd:Include_Organizations>
        <wd:Include_Roles>false</wd:Include_Roles>
      </wd:Response_Group>
    </wd:Get_Workers_Request>
  </soapenv:Body>
</soapenv:Envelope>

What each part comes from:

  • Version. wd:version="v47.0" on the request element, and the same version at the end of the endpoint path. v47.0 shipped with Workday 2026R2 on September 18, 2026 (version directory). The directory lists every supported version back to v9, so pin one and upgrade on purpose.
  • Endpoint. The WSDL does not carry your tenant's address. Microsoft's Workday provisioning guide documents the format as https://<host>.workday.com/ccx/service/<tenant>/Human_Resources/v<version> (Microsoft Learn). Confirm the host for your tenant with your Workday administrator.
  • Authentication. A WS-Security UsernameToken for an integration system user, with the user name formatted as name@tenant (same Microsoft guide; namespace and PasswordText type from the OASIS UsernameToken Profile 1.0). The values here are placeholders. Load the real ones from a secret store at send time and never log the envelope.
  • Element order. The binding is document/literal, so order matters: Request_References, Request_Criteria, Response_Filter, Response_Group (Human_Resources v47.0 WSDL).
  • Response group. Without one, Get_Workers returns reference, personal, employment, compensation, organization and role data by default. Ask only for what you load. It cuts payload size and keeps personal data you do not need out of your pipeline.
  • Full snapshot. The first run sends no Request_Criteria at all, so it returns every worker as of the pinned entry moment.

The response

This response is synthetic. It was produced by synthetic_workday.py for the request above, not by Workday. It uses v47.0 element names and keeps only the elements the example reads; a real response carries much more.

<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
  <env:Body>
    <wd:Get_Workers_Response xmlns:wd="urn:com.workday/bsvc" wd:version="v47.0">
      <wd:Response_Filter>
        <wd:As_Of_Effective_Date>2026-10-10</wd:As_Of_Effective_Date>
        <wd:As_Of_Entry_DateTime>2026-10-10T17:00:00-07:00</wd:As_Of_Entry_DateTime>
        <wd:Page>1</wd:Page>
        <wd:Count>2</wd:Count>
      </wd:Response_Filter>
      <wd:Response_Results>
        <wd:Total_Results>12</wd:Total_Results>
        <wd:Total_Pages>6</wd:Total_Pages>
        <wd:Page_Results>2</wd:Page_Results>
        <wd:Page>1</wd:Page>
      </wd:Response_Results>
      <wd:Response_Data>
        <wd:Worker>
          <wd:Worker_Reference>
            <wd:ID wd:type="WID">00000000000000000000000000000007</wd:ID>
            <wd:ID wd:type="Employee_ID">SYN-0007</wd:ID>
          </wd:Worker_Reference>
          <wd:Worker_Descriptor>Synthetic Worker 0007</wd:Worker_Descriptor>
          <wd:Worker_Data>
            <wd:Worker_ID>SYN-0007</wd:Worker_ID>
            <wd:Employment_Data>
              <wd:Worker_Status_Data>
                <wd:Active>false</wd:Active>
              </wd:Worker_Status_Data>
            </wd:Employment_Data>
          </wd:Worker_Data>
        </wd:Worker>
        <wd:Worker>
          <wd:Worker_Reference>
            <wd:ID wd:type="WID">00000000000000000000000000000042</wd:ID>
            <wd:ID wd:type="Employee_ID">SYN-0042</wd:ID>
          </wd:Worker_Reference>
          <wd:Worker_Descriptor>Synthetic Worker 0042 (renamed)</wd:Worker_Descriptor>
          <wd:Worker_Data>
            <wd:Worker_ID>SYN-0042</wd:Worker_ID>
            <wd:Employment_Data>
              <wd:Worker_Status_Data>
                <wd:Active>true</wd:Active>
              </wd:Worker_Status_Data>
            </wd:Employment_Data>
          </wd:Worker_Data>
        </wd:Worker>
      </wd:Response_Data>
    </wd:Get_Workers_Response>
  </env:Body>
</env:Envelope>

Two details are worth copying into a real parser. The entry moment comes back in Pacific time: Workday documents that WWS returns datetimes as UTC-07:00 or UTC-08:00 depending on the API version, and that integration system users retrieve data on Pacific time (limits page). Compare instants, not strings. And key rows by the WID ID, which identifies the worker instance, rather than by display values.

Pagination and the response filter

The Get_Workers reference defines paging entirely in Response_Filter and Response_Results:

Element Where What Workday documents
Page Request Page number, default 1. "Page number cannot be zero."
Count Request Objects per page, "between 1 and 999. The default value is 100."
As_Of_Entry_DateTime Request Entry moment; defaults to the current date and time. When you set Page, set this too "to ensure that the result set remains the same between your requests."
As_Of_Effective_Date Request Effective moment; defaults to today
Total_Results, Total_Pages Response Size of the whole result set
Page_Results, Page Response Size and number of this page
Updated_From, Updated_Through Request, under Transaction_Log_Criteria_Data If one is set, both are required; Updated_Through must be less than or equal to the current moment

Three things in that table drive the design.

Pin the entry moment. Without As_Of_Entry_DateTime, each page defaults to "now", so a hire entered between page 1 and page 2 can shift every later page. The example's first negative control does exactly that with 10 workers per page: one worker is delivered twice, the last worker is never delivered, and Total_Pages from page 1 is now one short.

Read the totals from the response. The Total_Pages description says "A page of data in a WWS has a 100 entry maximum", while Count accepts up to 999. The two statements disagree. The example never computes the page count itself; it uses Total_Pages from page 1 and checks every later page against it.

Respect the paging cache. Workday's limits page says page 1 often takes longer because Workday gathers all results and caches them, so avoid a strict timeout on page 1. The cache is kept 2 hours between paged requests and 30 minutes after you request the last page. The example gives page 1 a 600-second timeout and later pages 120 seconds. Those are illustrative values, not measurements.

The page loop in extract.py fetches, checks, then commits the page and its checkpoint together:

    while window["total_pages"] is None or number <= window["total_pages"]:
        page = call_with_retries(
            lambda: fetch_page(service, window, number, count=count, timeouts=timeouts),
            label=f"page {number}",
            max_attempts=max_attempts,
            base_delay=base_delay,
            max_delay=max_delay,
            rng=rng,
            sleep=sleep,
            log=log,
        )
        check_page(window, number, page)
        window = {
            **window,
            "pages_done": number,
            "total_results": page.total_results,
            "total_pages": page.total_pages,
            "last_page_at": iso(now()),
        }
        destination.commit_page(
            rows=page.workers,
            window_id=window["as_of_entry"],
            page=number,
            state={STATE_KEY: {"complete_through": cursor["complete_through"], "window": window}},
        )

check_page fails the run if a page reports a different Page, a different entry moment, a Page_Results that does not match the workers it contains, or totals that differ from page 1.

Throttling and retries

Workday's limits page is specific about SOAP. Under high load, Workday rejects WWS SOAP requests with HTTP 500 Internal Service Error, not 429. It says the 500 "can sometimes be caused by too many requests", and recommends an exponential back-off retry mechanism and caching. HTTP 429 is what REST and RaaS callers get, and what any integration gets when it generates "an excessive volume of invalid requests". That last point is a good reason never to retry a request Workday has already called invalid.

SOAP itself complicates the 500. SOAP 1.1 requires a server to return HTTP 500 for every SOAP fault (section 6.2), so a malformed request and an overloaded tenant can share a status code. The fault body tells them apart. The v47.0 WSDL declares three faults for Get_Workers: Validation_Fault, Processing_Fault and Authentication_Fault. SOAP 1.1 says a Client-class fault "should not be resent without change", while a Server-class fault "may succeed at a later point in time".

Response Example policy Basis
HTTP 500, no readable SOAP fault Retry with backoff Workday: throttled SOAP returns 500
HTTP 500, Server-class fault Retry with backoff, bounded SOAP 1.1 Server class
HTTP 500, Validation_Fault or Authentication_Fault Fail fast WSDL fault types; retrying invalid requests can trigger 429
HTTP 500, any other Client-class fault Fail fast SOAP 1.1 Client class
HTTP 429 Retry; honor Retry-After if present Workday limits page; RFC 9110 section 10.2.3
HTTP 502, 503, 504 Retry; honor Retry-After if present Generic HTTP semantics, not Workday-specific
Timeout or dropped connection Retry Page 1 may be slow by design
Any other 4xx Fail fast Retrying will not change the answer

Workday does not document which Processing_Fault codes are transient. The example treats one as retryable only when its faultcode is Server-class, and caps it at the same 5 attempts as everything else. Log the Code and Detail_Message from the fault so you can tighten the rule for your tenant.

def classify_failure(status, headers, body):
    """Raise TransientError or PermanentError for a non-200 response."""
    if status == 429:
        raise TransientError("HTTP 429", _retry_after(headers))
    if status in (502, 503, 504):
        raise TransientError(f"HTTP {status}", _retry_after(headers))
    if status == 500:
        fault = _fault(body)
        if fault is None:
            # Workday: "The 500 error code can sometimes be caused by too many requests."
            raise TransientError("HTTP 500 without a SOAP fault")
        code, details, message = fault
        fault_class = code.split(":")[-1].split(".")[0]
        if "Validation_Fault" in details or "Authentication_Fault" in details or fault_class == "Client":
            raise PermanentError(f"SOAP fault {code} {details}: {message}")
        raise TransientError(f"SOAP fault {code} {details}: {message}")
    raise PermanentError(f"HTTP {status}")

The retry loop uses exponential backoff with full jitter: each delay is random between zero and a capped exponential bound, so parallel jobs do not retry in lockstep (AWS Architecture Blog). A Retry-After value replaces the computed delay.

def backoff_delay(attempt, *, base, cap, rng):
    """Full jitter: a random delay between 0 and the capped exponential bound."""
    return rng.uniform(0, min(cap, base * 2 ** (attempt - 1)))


def call_with_retries(request, *, label, max_attempts, base_delay, max_delay, rng, sleep, log):
    for attempt in range(1, max_attempts + 1):
        try:
            return request()
        except TransientError as exc:
            if attempt == max_attempts:
                raise ExtractionFailed(f"{label}: {exc}: still failing after {max_attempts} attempts") from exc
            if exc.retry_after is not None:
                delay = exc.retry_after
            else:
                delay = backoff_delay(attempt, base=base_delay, cap=max_delay, rng=rng)
            log(f"{label}: {exc}: attempt {attempt}/{max_attempts}, retrying in {delay:.1f}s")
            sleep(delay)
        except PermanentError as exc:
            raise ExtractionFailed(f"{label}: {exc}: not retryable") from exc

What Workday does not publish matters as much. The limits page gives request quotas for RaaS extracts by tenant size, but no requests-per-second or concurrency figure for WWS SOAP. Do not design around a number you found in a blog post. Run one extraction at a time per tenant unless you have measured otherwise, and let backoff absorb the rest. For its own scheduled EIB integrations, the same page recommends a random start time such as 9:10 instead of the top of the hour to avoid peak queuing; the reasoning carries over.

Restart-safe state

Retries cover a blip inside one run. When they are exhausted, the run fails and the next run has to pick up cleanly. Separate what Workday documents in the Get_Workers reference and the limits page from what you add.

Workday documents The extractor adds
A pinned As_Of_Entry_DateTime keeps the result set the same between paged requests Stores the entry moment in the checkpoint and reuses it after a restart
The paging cache is kept 2 hours between requests and 30 minutes after the last page Resumes at the next page only if the last page was committed less than 90 minutes ago; otherwise replays the window from page 1
Total_Results and Total_Pages describe the result set Stores them from page 1 and fails any page or window that disagrees
Nothing about the destination Commits rows, a load log and the checkpoint in one transaction, and upserts by WID

Workday does not document what happens if you request page 4 after the paging cache has expired, or whether the order of results survives a rebuilt cache. The 90-minute threshold leaves margin inside the documented 2 hours, and the replay costs a few pages instead of a silent gap.

This is the checkpoint after run 1 of the demo fails on page 3:

{
  "workday_workers_v1": {
    "complete_through": null,
    "window": {
      "as_of_entry": "2026-10-09T12:00:00Z",
      "last_page_at": "2026-10-09T12:05:00Z",
      "pages_done": 2,
      "total_pages": 5,
      "total_results": 230,
      "updated_from": null,
      "updated_through": null
    }
  }
}

complete_through only moves after a whole window passes its check. The in-progress window holds everything needed to repeat the exact same query: the window bounds, the pinned entry moment, the page reached, and the totals page 1 reported. The state key is versioned so a later change to its shape can be migrated instead of misread, and read_state rejects any shape it does not recognize before a single request goes out.

If your warehouse cannot commit rows and state in one transaction, write the rows first and the checkpoint second. A crash between the two replays one page, and upsert by WID absorbs it. Never write the checkpoint first; the example's third negative control shows that loses the page in flight.

Reconciliation for missing and duplicate records

There are two checks, and they catch different failures.

The window check runs at the end of every window, using only the load log the extractor wrote. It needs no second call to Workday:

    problems = []
    # An empty result can report Total_Pages 0, but page 1 was still requested.
    expected_pages = list(range(1, max(total_pages, 1) + 1))
    if sorted(deliveries) != expected_pages:
        problems.append(f"pages {sorted(deliveries)} delivered, expected 1-{max(total_pages, 1)}")
    pages_by_key = {}
    for page, keys in deliveries.items():
        for key in keys:
            pages_by_key.setdefault(key, []).append(page)
    repeated = sorted(key for key, pages in pages_by_key.items() if len(pages) > 1)
    if repeated:
        problems.append(f"{len(repeated)} WIDs delivered on more than one page")
    if len(pages_by_key) != total_results:
        problems.append(f"{len(pages_by_key)} distinct WIDs delivered, Total_Results was {total_results}")
    return problems

The destination check compares the table with a source of truth by WID and reports missing, unexpected, duplicate and stale rows. In the example the synthetic service is the source of truth. Against a real tenant, use a periodic full snapshot pulled with its own pinned entry moment, and compare WID sets and a few key fields.

python3 demo.py runs the whole scenario. Page 3 of the snapshot returns HTTP 500 with no SOAP body five times:

run 1: full snapshot; page 3 returns HTTP 500 five times
  start snapshot as of 2026-10-09T12:00:00Z
  page 1/5: committed 50 workers
  page 2/5: committed 50 workers
  page 3: HTTP 500 without a SOAP fault: attempt 1/5, retrying in 0.6s
  page 3: HTTP 500 without a SOAP fault: attempt 2/5, retrying in 0.6s
  page 3: HTTP 500 without a SOAP fault: attempt 3/5, retrying in 5.2s
  page 3: HTTP 500 without a SOAP fault: attempt 4/5, retrying in 1.2s
  FAILED: page 3: HTTP 500 without a SOAP fault: still failing after 5 attempts
  reconciliation against the synthetic source as of 2026-10-09T12:00:00Z:
    expected workers:  230
    rows present:      100
    missing WIDs:      101, 102, 103, 104, 105 (+125 more)
    unexpected WIDs:   none
    duplicate WIDs:    none
    stale WIDs:        none
    result:            FAIL

A failing reconciliation is the correct result at that point: two pages were committed and three were not. The next run, ten minutes later, resumes the same pinned query at page 3:

run 2: ten minutes later
  resume as of 2026-10-09T12:00:00Z at page 3
  page 3/5: committed 50 workers
  page 4/5: committed 50 workers
  page 5/5: committed 30 workers
  complete through 2026-10-09T12:00:00Z: 230 workers in 5 pages
  reconciliation against the synthetic source as of 2026-10-09T12:00:00Z:
    expected workers:  230
    rows present:      230
    missing WIDs:      none
    unexpected WIDs:   none
    duplicate WIDs:    none
    stale WIDs:        none
    result:            OK

The third run reads the transaction log for October 10: one termination, one rename and ten new hires. It lands 12 workers and reconciles 240 of 240 with current values.

A check that has never failed proves little, so test_negative_controls.py implements four plausible bugs and asserts the checks catch each one:

Broken extraction What goes wrong What catches it
No As_Of_Entry_DateTime A hire entered after page 1 shifts later pages: WID 10 arrives twice, WID 230 never arrives Window check: 1 WID on two pages, 229 distinct vs Total_Results 230; destination: WID 230 missing
range(1, total_pages) The last page is never requested Window check: pages 1 to 4 of 5; destination: WIDs 201 to 230 missing
Checkpoint written before the page loads Page 3 fails after its checkpoint, the restart skips it Destination: WIDs 101 to 150 missing
Rows appended, no primary key The incremental window adds second copies of updated workers Destination: WIDs 7 and 42 duplicated, 242 rows for 240 workers

The positive suite also covers a page served in a different order with matching totals, which only the window check can see, and a run whose window would end in the future, which the synthetic service rejects with Workday's documented validation message and the extractor does not retry. I also removed each safeguard from extract.py by hand, one at a time: the pin, the page check, the window check, the stale-checkpoint replay, the fault classification and Retry-After. The suite failed every time.

Monitoring and recovery

Workday's limits page notes that Workday does not log GET requests or unsuccessful requests, and suggests external integration HTTP headers so your requests can be traced in its server logs. Treat your own logs as the record of what the extractor asked for.

Log per page, never per worker, and never the envelope:

  • Window bounds, pinned entry moment, page number, Total_Results, Total_Pages, and rows committed
  • Each retry: classification, attempt number, delay
  • Each permanent failure: the fault type, Code and Detail_Message, with no request body

Alert on these, in roughly this order:

Signal Likely cause First action
Run fails after exhausting retries Tenant under load, or a long outage Let the next run resume; if it keeps failing, move the schedule off peak or lower Count
Validation_Fault Request no longer valid for this version or criteria Fix the request; do not retry
Authentication_Fault Expired password or a changed integration system user Fix credentials; nothing to resume
HTTP 429 Too many invalid requests, or a REST or RaaS limit Stop retrying invalid requests first
Window check fails Unpinned entry moment, a page skipped, or results reordered Leave complete_through where it is, fix, replay the window
complete_through stops advancing Repeated failures, or the window check failing quietly Read the checkpoint and the last run's log
Destination check finds missing or stale WIDs A change the transaction log window did not return Run a full snapshot reconciliation

Recovery is the same in every case. The checkpoint is the only thing the next run trusts, so fix the cause, decide whether to keep the in-progress window or clear it, and rerun. Clearing window and leaving complete_through alone replays that window from scratch with a new entry moment; upsert by WID keeps it safe.

If a production Workday pipeline already shows these symptoms and the cause is not obvious, that is what Pipeline Rescue is for: a fixed-scope diagnosis of the pipeline you already have.

Limitations

  • Synthetic service. The example models Workday's documented paging and validation, not its real ordering, latency or throttling. Nothing was run against a tenant.
  • Effective-dated changes. Get_Workers returns data "as of the dates in the response filter" (reference). A change entered today with an effective date next month comes back in today's window with today's values, and may not appear in a later transaction log window when it takes effect. A periodic full snapshot reconciliation is the backstop; verify the behavior in your tenant.
  • Organization changes. Workday notes that changes to organization data such as name and code are not captured in the transaction log, so a worker-level window will not see them (Transaction_Log_Criteria).
  • Rescinds and corrections are not modeled.
  • Response size. Workday caps a web service response at 1 million instances and outbound responses at 2 GB (limits page). Pages of 100 stay far below that, but parse large pages incrementally, and follow the Python XML security guidance for the parser you use.
  • No performance numbers. The clock is fake. The timeouts and backoff values are illustrative starting points to tune against your tenant.

Running this as a Fivetran connector

The extraction logic does not depend on any connector framework. To run it under the Fivetran Connector SDK, the checkpoint becomes Fivetran state and commit_page becomes upserts followed by a checkpoint. The Fivetran Connector SDK Production Guide: State, Retries and Restart Safety covers that side, with its own runnable restart test and the Fivetran behaviors it depends on. For slow APIs where checkpoint cadence dominates, see the CX Alloy connector notes.

Related reading

Sources

Workday documentation, checked October 11, 2026:

Specifications and other references:

Related practical notes