Void a Payroll
Void a Payroll
Reverses payments from a payroll that has already been finalized — for example, when a paystub was calculated incorrectly or paid to the wrong person. Voiding doesn't cancel or delete the original paystub. It reverses it: Worklio starts a new payroll run that contains a negative copy of each paystub you void, and optionally a replacement paystub. The original paystub isn't edited and stays in the original payroll with its original amounts.
Voiding a direct deposit paystub can debit the employee's bank account. Read What happens to the payment before you call this endpoint.
Requires a bearer access token (see How to Get API Access). Only Admin and System level users can call the void endpoints; employee-level tokens get a 403 Forbidden, even for their own company. CLIENT_ID is the company identifier returned as id when you create the company.
Steps at a glance
| Step | What you do | Required |
|---|---|---|
| 1. Find the payroll to void | Get the payroll's runId and pay date. | Yes |
| 2. Find the employee's paystub | Get the employeeId and paystubId to void. | Yes |
| 3. Start the void run | Create the void run. You get a new runId. | Yes |
| 4. Change the pay on the replacement paystub | Get the replacement paystub and PATCH its pay lines. | Optional. Skip it to keep the original pay. |
| 5. Advance and finalize the run | Complete the void. | Yes |
How voiding works
When you void a paystub, Worklio:
- Creates a void paystub in the new void run. It is a copy of the original with every amount negated, including gross pay, taxes, deductions, and the payment itself. It keeps the original check number.
- Depending on the mode you choose, creates a replacement paystub in the same run.
- When you finalize the void run, flags the original paystub as voided. The original payroll's overview and paystub amounts don't change. You can tell a paystub has been voided because it no longer appears in the lookup endpoints, the void paystub is in the void run, and voiding it again fails with
AlreadyVoided.
There are three modes. The mode is set by the reissue field and by whether the original payroll's pay date is in the same year as the void run's payDate.
| Mode | When | What the run contains |
|---|---|---|
| Void only | reissue is false, and the original paystub is from the same year as the void run's payDate. | A void paystub only. |
| Void and re-key | reissue is true (the default), and the original paystub is from the same year as the void run's payDate. | A void paystub, plus a new paystub that starts from the original's pay lines and is recalculated in the void run. The run pauses at the timesheet step. You can change the pay on the new paystub before it is paid, or skip that step to keep the original pay. |
| Void and reissue | The original paystub is from an earlier year than the void run's payDate. reissue must be true. | A void paystub, plus a full, unchanged copy of the original paystub. |
In the API, reissue: true covers both replacement modes. Whether you get a re-key or a reissue depends only on the year of the original paystub.
A paystub that is itself a void can't be reissued. Voiding it again with reissue set to false reverses the void.
What happens to the payment
The void paystub reverses the original payment. What that means depends on how the employee was paid.
| Original payment | Void only | Void and re-key | Void and reissue |
|---|---|---|---|
| Check | The check is marked void. No money moves. | The check is marked void and a new check is issued for the re-keyed paystub. | The check is marked void and a new check is issued for the reissued paystub. |
| Direct deposit | A negative payment is created for the full amount. It is sent as a debit to the employee's bank account. | A negative payment for the original amount and a new payment for the re-keyed amount are created with the same pay date. The net change in the employee's account is the difference between the two. | A negative payment and an identical positive payment are created. The net change in the employee's account is zero. |
Notes:
- Voiding a check doesn't recover a check the employee has already cashed.
- The debit goes to the bank account that received the original payment. The replacement payment goes to the employee's pay allocations as they are when the void run is calculated. If the employee's pay allocations have changed since the original payroll, the debit and the replacement payment can go to different accounts and won't offset each other.
- A void doesn't cancel a payment that was already sent. It creates a new, opposite payment.
Example: voiding a $2,000.00 paycheck
An employee was paid on August 14, 2026. The paystub has $2,000.00 gross pay and $700.00 of employee taxes, so net pay is $1,300.00, paid by direct deposit. The employer's share of Social Security and Medicare is $153.00. On October 15, 2026, you void the paystub with reissue set to false.
| Original paystub (August 14) | Void paystub (October 15) | Total | |
|---|---|---|---|
| Gross pay | 2,000.00 | −2,000.00 | 0.00 |
| Employee taxes | 700.00 | −700.00 | 0.00 |
| Net pay (direct deposit) | 1,300.00 | −1,300.00 | 0.00 |
| Employer Social Security and Medicare | 153.00 | −153.00 | 0.00 |
- The original paystub isn't edited. It stays in the August payroll with its original amounts.
- The void paystub belongs to the October run. Its taxes are negative amounts dated on the void run's pay date, not in August.
- The employee's account is debited $1,300.00.
If you void and re-key instead, and the corrected paystub comes to $1,800.00 gross, $630.00 of employee taxes, and $1,170.00 net, the $1,300.00 debit and the $1,170.00 new payment are in the same run. The employee's account is debited the $130.00 difference. The steps below follow this re-key example.
1. Find the payroll to void
Find the payroll that contains the paystub you want to void. All lookup endpoints in steps 1 and 2 are GET requests with no body, and they only return payrolls from the payroll policy in the path.
| Purpose | Endpoint |
|---|---|
| Years that have voidable payrolls | GET /wep/companies/{CLIENT_ID}/policies/{POLICY_ID}/void |
| Payrolls in a year | GET /wep/companies/{CLIENT_ID}/policies/{POLICY_ID}/void/{YEAR}/payrolls |
| Payrolls in a year | GET /wep/companies/{CLIENT_ID}/payroll/void/{YEAR}/payrolls |
POLICY_ID is the payroll policy's id — see Payroll Policies. YEAR is a four-digit year, such as 2026.
Only finalized payrolls and paystubs that haven't been voided are returned.
Response fields
Years — an array of integers, newest first.
Payrolls — an array of objects:
| Field | Type | Description |
|---|---|---|
runId | integer | The payroll run's ID. |
payDay | string (date, YYYY-MM-DD) | The date employees were paid for the run. |
payrollType | integer (enum) | The type of the original run. |
totalGross | number | Total gross pay for the run. |
deadline | string (date-time) | Processing deadline for the run. |
daysForProcess | integer | Number of days allowed to process payments. |
Note the payDay of the payroll you are voiding. In step 2, each paystub has its own payDay, which you use to pick the paystub that belongs to this payroll.
Example request
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const CLIENT_ID = process.env.CLIENT_ID;
const url = `https://api.worklio.com/wep/companies/${CLIENT_ID}/payroll/void/2026/payrolls`;
async function getPayrollsToVoid() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getPayrollsToVoid();# TODO: add example request — list payrolls in a yearExample response
[
{
"runId": 1002171,
"payDay": "2026-10-09",
"deadline": "2026-10-05T21:00:00Z",
"daysForProcess": 4,
"payrollType": 2,
"totalGross": 90000
}
]2. Find the employee's paystub
You need the employeeId and paystubId of each paystub you want to void.
GET /wep/companies/{CLIENT_ID}/payroll/void/{YEAR}/employees/{EMPLOYEE_ID}Response fields
Employees — an array of objects:
| Field | Type | Description |
|---|---|---|
id | integer | The employee's ID. Pass it as employeeId in paystubs. |
firstName | string | First name. |
middleName | string | null | Middle name. |
lastName | string | Last name. |
Paystubs — an array of objects, newest first:
| Field | Type | Description |
|---|---|---|
paystubId | integer | The paystub's ID. Pass it as paystubId in paystubs. |
payrollNumber | string | The paystub's check number, formatted. |
payDay | string (date, YYYY-MM-DD) | The date the employee was paid. |
gross | number | Gross pay on the paystub. |
netPay | number | Net pay on the paystub. |
deadline | string (date-time) | Processing deadline for the original run. |
daysForProcess | integer | Number of days allowed to process payments. |
Example request
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const CLIENT_ID = process.env.CLIENT_ID;
const EMPLOYEE_ID = process.env.EMPLOYEE_ID
const url = `https://api.worklio.com/wep/companies/${CLIENT_ID}/payroll/void/2026/employees/${EMPLOYEE_ID}`;
async function getEmployeePaystubsToVoid() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getEmployeePaystubsToVoid()();# TODO: add example request — list one employee's paystubs for a yearExample response
[
{
"paystubId": 10239,
"payDay": "2026-10-09",
"deadline": "2026-10-05T21:00:00Z",
"daysForProcess": 4,
"payrollNumber": "00105800010000004",
"netPay": 1300,
"gross": 2000
}
]3. Start the void run
POST https://api.worklio.com/wep/companies/{CLIENT_ID}/payroll/void
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
voidYear | int32 | null | Yes, in practice | The year of the payrolls you are voiding. Defaults to 2024 if you omit it, which gives wrong results for any other year — always send it. |
startDate | string (date, YYYY-MM-DD) | Yes | Pay period start date of the void run. |
endDate | string (date, YYYY-MM-DD) | Yes | Pay period end date of the void run. |
payDate | string (date, YYYY-MM-DD) | Yes | Pay date of the void run. Replacement and offsetting payments use this date. |
reissue | boolean | null | No | true (the default) creates a replacement paystub for each voided paystub. false voids only. Must be true when voidYear is earlier than the year of payDate. See How voiding works. |
paystubs | array of objects | Yes | The paystubs to void. See below. |
startDate, endDate, and payDate describe the void run, not the original payroll.
paystubs object
paystubs object| Field | Type | Required | Description |
|---|---|---|---|
employeeId | int32 | Yes | The employee's ID from step 2. |
paystubId | int32 | Yes | The ID of the paystub to void, from step 2. |
Example request
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const CLIENT_ID = process.env.CLIENT_ID;
const url = `https://api.worklio.com/wep/companies/${CLIENT_ID}/payroll/void`;
const payload = {
voidYear: 2026,
startDate: "2026-10-15",
endDate: "2026-10-15",
payDate: "2026-10-15",
reissue: true,
paystubs: [
{
employeeId: 4281,
paystubId: 10238
}
]
};
async function startVoidPayroll() {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
startVoidPayroll();curl -s -X POST "https://api.worklio.com/wep/companies/$CLIENT_ID/payroll/void" \
-H "accept: application/json" \
-H "api-version: 2.0" \
-H "authorization: Bearer $API_KEY" \
-H "content-type: application/json" \
-H "x-api-version: 2.0" \
-d '{
"voidYear": 2026,
"startDate": "2026-10-15",
"endDate": "2026-10-15",
"payDate": "2026-10-15",
"reissue": true,
"paystubs": [
{
"employeeId": 4281,
"paystubId": 10238
}
]
}' | jq .Example response
{
"companyId": 1030,
"runId": 1002191,
"payrollType": 2,
"startedOn": "2026-10-15",
"payDay": "2026-10-15",
"periodStart": "2026-10-15",
"periodEnd": "2026-10-15",
"daysToProcessPayments": 4,
"deadline": "2026-10-11T21:00:00Z",
"currentUIStep": 0,
"currentProcStep": 501,
"currentProcStepDesc": "Payroll Engine Activated",
"currentProcStepProgress": 0,
"currentProcStepStatus": 1,
"direction": 0,
"isPayrollBlocked": false,
"canUserOverride": false,
"errors": [],
"warnings": []
}The response is the same run status object returned by Run Payroll. runId is the ID of the void run. It is a new ID, not the runId of the payroll you found in step 1. Use it for every later call.
To void without a replacement paystub, send "reissue": false and skip step 4.
Error response — reissue not set for a prior-year void
{
"status": 0,
"code": "500",
"errorCode": null,
"message": "10/15/2026 - 11:14:55 AM : Reissue is required for voiding payroll from previous year",
"stackTrace": "",
"pagination": {
"pageNo": 0,
"pageSize": 0,
"totalRecords": 0,
"totalPages": 0,
"dataToken": ""
},
"validationErrors": null
}Returned when reissue is false and voidYear isn't the same year as payDate.
4. (Optional) Change the pay on the replacement paystub
You can skip this step. If you don't change anything, the replacement paystub keeps the original paystub's pay and you go straight to step 5.
Use this step when the original paystub was paid too much or too little and you want the replacement paystub to carry the correct pay. Skip it if you only want to void, or if the replacement paystub should have the same pay as the original.
This step applies only to a void and re-key run: reissue is true and voidYear is the same year as payDate. A void and reissue run, and a void-only run, have no editable paystub.
Before you start:
- You can change pay only while the run is paused at the timesheet step. After you advance past it, the request fails with
TimeEntryIsNotEditable. - Void paystubs are read-only and aren't returned by this step's endpoints.
- You can change only the pay lines you can edit in a regular payroll. System-generated pay lines can't be changed.
- Worklio recalculates taxes on the new amounts. You don't enter taxes.
4a. Get the replacement paystub
GET https://api.worklio.com/wep/companies/{CLIENT_ID}/payroll/{RUN_ID}/paystubs
RUN_ID is the runId of the void run from step 3. The response lists the editable paystubs in the void run, which are the replacement paystubs. Note the paystub id and the id of each pay line you want to change.
The paystub id returned here belongs to the replacement paystub in the void run. It is not the paystubId of the original paystub you voided in step 3.
Example request
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const CLIENT_ID = process.env.CLIENT_ID;
const RUN_ID = 1002191;
const url = `https://api.worklio.com/wep/companies/${CLIENT_ID}/payroll/${RUN_ID}/paystubs`;
async function getVoidRunPaystubs() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"x-api-version": "2.0"
}
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getVoidRunPaystubs();curl -s -X GET "https://api.worklio.com/wep/companies/$CLIENT_ID/payroll/1002191/paystubs" \
-H "accept: application/json" \
-H "api-version: 2.0" \
-H "authorization: Bearer $API_KEY" \
-H "x-api-version: 2.0" | jq .Example response
[
{
"employeeName": "Brown, Michael",
"id": 10264,
"compensation": 1,
"allowedNetPayAlloc": true,
"employeeId": 4281,
"paymentMethod": 1,
"paylines": [
{
"id": 10291,
"rate": 50,
"salaryWithHours": {
"amount": 2000,
"hours": 40
},
"wcCode": "0000"
}
]
}
]4b. Update the pay lines
PATCH https://api.worklio.com/wep/companies/{CLIENT_ID}/payroll/{RUN_ID}/paystubs/{PAYSTUB_ID}
PAYSTUB_ID is the paystub id from step 4a.
Send only the pay lines you want to change. Each pay line is identified by its id, and the body contains the fields for the line's pay type.
| Field | Type | Required | Description |
|---|---|---|---|
paylines | array of objects | Yes | The pay lines to change. |
paylines[].id | integer | Yes | The pay line's ID from the step 4a response. |
paylines[].rate | number | No | The pay rate. |
paylines[].hourly.hours | number | No | Hours on an hourly pay line. |
paylines[].overtime.hours | number | No | Hours on an overtime pay line. |
paylines[].pto.hours | number | No | Hours on a paid time off pay line. |
paylines[].sickTime.hours | number | No | Hours on a sick time pay line. |
paylines[].unitsWithHours | object | No | hours and units on a piece-rate pay line. |
paylines[].salaryWithHours | object | No | amount and hours on a salary pay line. |
paylines[].bonus.amount, commission.amount, reimbursement.amount, other.amount | number | No | The amount on a bonus, commission, reimbursement, or other pay line. |
The body can also contain paymentMethod and deductions.
Example request
In this example, the original weekly salary of $2,000.00 should have been $1,800.00.
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const CLIENT_ID = process.env.CLIENT_ID;
const RUN_ID = 1002191;
const PAYSTUB_ID = 10264;
const url = `https://api.worklio.com/wep/companies/${CLIENT_ID}/payroll/${RUN_ID}/paystubs/${PAYSTUB_ID}`;
const payload = {
paylines: [
{
id: 10291,
salaryWithHours: { amount: 1800 }
}
]
};
async function updatePaystub() {
const response = await fetch(url, {
method: "PATCH",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
updatePaystub();curl -s -X PATCH "https://api.worklio.com/wep/companies/$CLIENT_ID/payroll/1002191/paystubs/10264" \
-H "accept: application/json" \
-H "api-version: 2.0" \
-H "authorization: Bearer $API_KEY" \
-H "content-type: application/json" \
-H "x-api-version: 2.0" \
-d '{
"paylines": [
{
"id": 10291,
"salaryWithHours": { "amount": 1800 }
}
]
}' | jq .Example response
The response is the updated paystub. Only salaryWithHours.amount changed. hours and rate are returned as they were.
{
"employeeName": "Brown, Michael",
"id": 10264,
"compensation": 1,
"allowedNetPayAlloc": true,
"employeeId": 4281,
"paymentMethod": 1,
"paylines": [
{
"id": 10291,
"rate": 50,
"salaryWithHours": {
"amount": 1800,
"hours": 40
},
"wcCode": "0000"
}
]
}To add or remove a pay line on the paystub, use these endpoints:
| Purpose | Endpoint |
|---|---|
| Add a pay line | POST https://api.worklio.com/wep/companies/{CLIENT_ID}/payroll/{RUN_ID}/paystubs/{PAYSTUB_ID}/paylines/add |
| Remove a pay line | DELETE https://api.worklio.com/wep/companies/{CLIENT_ID}/payroll/{RUN_ID}/paystubs/{PAYSTUB_ID}/paylines/{PAYLINE_ID} |
5. Advance and finalize the run
The void run is processed in the background. Once you have runId (and have changed the pay in step 4, if you needed to), advance and finalize it the same way as a regular payroll run — see steps 2 and 3 in Run Payroll. Finalizing is what completes the void: the original paystub is flagged as voided and payments are released.
If the run is a void and re-key, it stops at the timesheet step until you advance it. After you finalize the run, it appears in Payroll History, and its reports are available through Payroll Reports.
When a paystub can't be voided
The call to start the run can return 200 even if a paystub was already void, check warnings to see.
{
"companyId": 1030,
"runId": 1002198,
"payrollType": 2,
"startedOn": "2026-10-15",
"payDay": "2026-10-15",
"periodStart": "2026-10-15",
"periodEnd": "2026-10-15",
"daysToProcessPayments": 4,
"deadline": "2026-10-11T21:00:00Z",
"currentUIStep": 0,
"currentProcStep": 701,
"skipUISteps": [],
"currentProcStepDesc": "Crunching time entry data …",
"currentProcStepProgress": 0,
"currentProcStepStatus": 1,
"direction": 0,
"isPayrollBlocked": false,
"canUserOverride": false,
"errors": [],
"warnings": [
"E: AlreadyVoided - The source pay statement was voided already."
]
}| Code | Meaning |
|---|---|
NotFound | No paystub with that paystubId exists. |
AlreadyVoided | The paystub has already been voided. |
PaRunNotFinalized | The paystub belongs to a payroll that hasn't been finalized. |
MethodNotAllowed | The paystub is from a previous year and reissue is false, or reissue is true and the paystub is itself a void. |
EmployeeIsNotInCurrentPaRun | The paystub's employee isn't in the void run, for example because the employee is in another payroll that is in progress. |
SourceCantBeSetup | The paystub comes from a setup payroll. Setup paystubs can't be voided here. |
SourceCantBeSickPay | The paystub comes from a third-party sick pay payroll. Sick pay paystubs can't be voided here. |
UncollectedTaxInAnotherPayroll | The paystub has uncollected-tax records on tips or group term life that are tied to another payroll that hasn't been finalized. |
Notes
- Not idempotent. Each call starts a new void run. If a call may have succeeded but you didn't get the response, check Payroll History for a void run before you retry.
- Changing pay is optional and limited to the timesheet step. Make
PATCHcalls before you advance the run. See step 4. - Pay date. If the user behind the token isn't allowed to back-date pay dates, a
payDateearlier than today is refused. - One void run at a time per employee. An employee can't be included in a void run while they're in another payroll that hasn't been finalized.
- Webhooks. Starting a void run sends the Payroll
Startedevent. See Webhook Event Types. - What a void run leaves out. It doesn't apply flat-amount deductions, arrears, scheduled pay, or adjustments, and it doesn't send a payday announcement email.
Updated 15 minutes ago
