Paystream

Stream the pay data you already collect into Salsa, and let payroll runs pick it up.

Paystream is the API for sending the payroll data your platform already holds to Salsa. Use it for employer details such as addresses and bank accounts; worker details such as contracts and payment accounts; and the entries that feed payroll: hours, shifts, salaries, commissions, tips, benefits, deductions, and time off. Send each record when you have it. Salsa uses the entry dates to include it in the right payroll run.

YOUR PLATFORMPAYSTREAMSALSAACME HOME CARE2 work sites · bank account · signatory3 WORKERSAna · address · hourly contractBen · address · salaried contractCy · address · contractorbank accounts · tax idsPAY DATAHourly pay · 8h · Jul 06Cash tips · $250 · Jul 09PTO · 8h · Jul 10Commission · $150 · Jul 15Send what you hold, as complete as you have it.POST /paystream/employersPOST /paystream/workersPOST /paystream/payroll-elementsthe whole record, every timePROFILESEmployer · ACME HOME CARE · er_8d4fWorker · Ana · wr_1a2bWorker · Ben · wr_3c4dWorker · Cy · wr_5e6fPAY PERIOD · JUL 06 → JUL 12Hourly pay · 8h · Jul 06Cash tips · $250 · Jul 09PTO · 8h · Jul 10NEXT PERIOD · JUL 13 → JUL 19Commission · $150 · Jul 15Salsa keeps the records payroll needsand picks each entry up by its dates.PAYROLL RUN · JUL 06 → JUL 12picks up every pending entry dated inside the period when it is calculatedENTRIES0 / 33 / 3The Jul 15 commission is not in this run;it waits for the next one.

In short: you send the employer, its workers and each pay entry as you hold them. Salsa keeps the employer and worker profiles and places each entry in the pay period that matches its dates; an entry dated after the period waits for the next run.

What Paystream is for

Most platforms already hold part of the payroll record. A time-tracking product has hours and rates; a financial-services product may have bank-account details; nearly every platform has employer and worker details. Paystream lets you pass that data to Salsa so the employer and workers do not need to enter it twice.

Rather than create each address, contract, bank account, and payroll entry through separate APIs, send the employer or worker record as you hold it. Salsa routes each part to the right record. For a practical migration and ongoing-sync pattern, see Sync employer and worker data.

Send what you have; Salsa collects the rest

Paystream fields are optional because each platform owns different data. Send the fields you own, and send nested objects in full when you update them. Salsa can collect the remaining information through the embedded employer and worker components: tax setup, bank-account verification, worker onboarding, and the pay, benefit, and time-off policies your platform does not manage. The employer can also provide outstanding payroll data in the payroll run.

Keep what you compute; let Salsa do the compliance math

If your platform already calculates a payment, send the approved amount and the dates it applies to. For example, if your platform applies tiered commission rules, send the commission amount it calculated; Salsa processes it as part of gross pay.

For pay that depends on payroll rules, send daily or shift-level hours with the applicable rate. Salsa uses the employer's and worker's applicable jurisdictions to calculate overtime, double time, and premiums. For time off, send the hours taken; Salsa manages accruals, balances, and the applicable payment rate. Your platform remains the source of truth for the data it owns, while Salsa manages the payroll rules and records it owns.

Which endpoint to use

EndpointWhat you send
POST /v1/paystream/employers (reference)Employers: business details, addresses, bank account, signatories, work sites
POST /v1/paystream/workers (reference)Workers: personal information, home address, contract, bank accounts, termination
POST /v1/paystream/payroll-elements (reference)Payroll elements for a set of workers over a period: pay, time worked, benefits, deductions and time off
POST /v1/paystream/risk-insight-inputs (reference)What you know about an employer's activity on your platform, for risk assessment; see Providing employer risk metrics
POST /v1/paystream (reference)Any mix of the above in one request

Every endpoint takes the same shape: a data list of items, each with a type, each carrying as much of the record as you hold. The dedicated endpoints are synchronous: the response tells you what was created or updated and which items failed. The generic POST /v1/paystream is asynchronous unless you add ?synchronous=true.

We recommend the synchronous endpoints for now. They confirm that the data was processed and tell you exactly what failed. The asynchronous endpoint accepts the request and processes it later. You can read records or preview a period to reconcile what Salsa holds, but those reads are not a per-request status receipt.

Use POST /v1/paystream when you need several item types in one request. It processes the items of a request in a fixed order, employers first, then workers, then payroll elements, then risk metrics, which is what lets you register an employer work site and reference it from a shift in the same call, as the Wage Parity guide does.

Every endpoint returns 201 with an envelope of data and errors. A request that fails validation returns 400 and writes nothing. A request where some workers fail and others succeed still returns 201, with one entry per failed worker in errors. Always read errors, not only the status code.

Sync employer and worker data

If your platform already has the employer and worker information needed to start payroll, send it to Salsa through Paystream. This avoids asking an employer to re-enter business details, or asking workers to repeat information your platform already collected.

Use this pattern when you are migrating an employer to Salsa or keeping records in sync after launch. It covers employer and worker records; Payroll elements, up close covers hours, pay, benefits, deductions, and time off.

Decide which system owns each field

Only send data that your platform owns and can keep current. Salsa can collect the rest through embedded onboarding components or during payroll.

Common examples of data to send from your platform are:

  • Employer business details, addresses, work sites, signatories, and bank-account details.
  • Worker names, contact details, home addresses, contracts, bank accounts, and tax information when your platform owns it.
  • The external IDs that identify those records in your system.

If Salsa owns a workflow, do not repeatedly overwrite its data. For example, Salsa can collect tax setup, verify a bank account, and manage payroll configuration. See Employer readiness and Worker readiness for the information needed before payroll can run.

Keep a durable mapping

Give every employer and worker that you send an externalId from your system, and store the Salsa ID returned by Paystream. Later updates can identify the record by either identifier; keeping both makes reconciliation and webhook handling straightforward.

Worker external IDs must be unique across all workers, not just within one employer. If your worker IDs are only unique within an employer, prefix them with the employer ID, for example acme-platform:worker-42.

For the full set of rules, see External IDs & Mapping to Salsa.

1. Send the employer record

Create the employer before its workers. Send the business information and any nested records that your platform already has. This example includes a business address and a bank account; add work sites or signatories only if you maintain them.

Endpoint: POST /v1/paystream/employers

{
  "data": [
    {
      "type": "PaystreamEmployerUpsertInput",
      "externalId": "acme-platform",
      "businessInfo": {
        "businessName": "Acme Home Care",
        "country": "USA",
        "employerType": "BUSINESS"
      },
      "addresses": [
        {
          "externalId": "acme-platform:main-office",
          "name": "Main office",
          "addressLine1": "123 Main Street",
          "addressLine2": null,
          "city": "Chicago",
          "state": "IL",
          "postalCode": "60601",
          "countryISO": "USA",
          "isWorkLocation": true,
          "isDefaultWorkLocation": true
        }
      ],
      "bankAccounts": [
        {
          "externalId": "acme-platform:operating",
          "displayName": "Operating account",
          "accountType": "CHECKING",
          "routingNumber": "121000358",
          "accountNumber": "123456789",
          "setAsEmployerDefaultBankAccount": true
        }
      ]
    }
  ]
}

Save the employer id from the response with acme-platform in your database. If you already verify the employer's bank account or collect payment authorization in your product, read Employer verified bank accounts before sending those fields.

2. Send workers with the information you have

Once the employer exists, create its workers. You can send a worker's name, personal information, home address, first contract, and bank accounts in one call. The example intentionally omits tax data and payment verification: Salsa can collect either through its worker flow when your platform does not own it.

Endpoint: POST /v1/paystream/workers

{
  "data": [
    {
      "type": "PaystreamWorkerUpsertInput",
      "employerId": "er_123456",
      "externalId": "acme-platform:worker-42",
      "firstName": "Ana",
      "lastName": "Garcia",
      "displayName": "Ana Garcia",
      "personalInfo": {
        "email": "[email protected]",
        "phoneNumber": {
          "number": "5551234567",
          "countryCode": "+1"
        }
      },
      "homeAddress": {
        "name": "Home",
        "addressLine1": "456 Oak Avenue",
        "city": "Chicago",
        "state": "IL",
        "postalCode": "60602",
        "countryISO": "USA"
      },
      "contract": {
        "classification": "EMPLOYEE",
        "hireDate": "2026-07-01",
        "overtimeEligible": true
      },
      "bankAccounts": [
        {
          "externalId": "acme-platform:worker-42:checking",
          "accountType": "CHECKING",
          "routingNumber": "121000358",
          "accountNumber": "987654789",
          "setAsExclusivePayDistributionRule": true
        }
      ]
    }
  ]
}

Store each returned Salsa workerId. Use it for future updates and to match the worker to payroll entries and webhooks.

3. Keep records current

When a record changes in your platform, send the relevant employer or worker update using either its Salsa ID or externalId. Send the complete homeAddress, because Salsa replaces the stored address in full. For personalInfo and names, send only the fields that changed; fields you omit keep their current value.

There are two important exceptions:

  • Worker profile updates, including termination, use POST /v1/paystream/workers; a worker contract is create-only in Paystream. Use the Worker Contract API for later contract changes.
  • A bank account that has been used for payroll is immutable. Add a new bank account instead of changing the existing account.

Recommended rollout

  1. Export each employer and its active workers from your platform.

  2. Send the employer, save its Salsa ID, then send its workers and save their Salsa IDs.

  3. Let Salsa collect the fields your platform does not own, and watch readiness webhooks to know when the employer or a worker needs attention.

  4. Send updates when source data changes. Send payroll entries separately through POST /v1/paystream/payroll-elements.

This sequence imports the records your platform owns and leaves Salsa to collect the rest.

📘

Related

The end-to-end launch is in Launching payroll with Salsa Express. The rest of this page focuses on payroll element entries: the pay, hours, benefit, deduction, and time-off records that feed a payroll run. For types, policies, and entries, see Payroll Elements.

Payroll elements, up close

The rest of this page is about POST /v1/paystream/payroll-elements, the endpoint that carries what a payroll run pays from. You send entries whenever you have them: days or weeks before payroll, while the employer is reviewing a payroll run, or after it. Each entry carries the dates it belongs to, and the payroll run whose pay period covers those dates picks it up.

An entry is one record of something a worker earned, worked, accrued, or should have deducted. Every entry is dated, references a type that Salsa configured for you during onboarding, and can carry your own identifier.

Entry listRequiredOptionalNotes
paypayReferenceId and a date pairamount, rate, hours, items, frequency, externalId, costDimensionValues, premiumEligibility, workLocationIdThe most used list. See How the amount is derived.
timeWorkedA date pairhours, externalId, premiumEligibility, workLocationIdHours that are not paid through an hourly pay entry, for overtime and premium calculations. Hours are computed from the shift when omitted.
benefitsbenefitReferenceId, startDate, endDateemployerAmount, workerAmount, externalId
deductionsdeductionReferenceId, startDate, endDateamount, externalId
timeOff.takentimeOffReferenceId, startDate, endDate, hoursrate, externalIdThe rate is derived from earnings when omitted.
timeOff.accruedtimeOffReferenceId, datehours, isUnlimited, externalIdhours is required unless isUnlimited is true.
timeOff.balanceAdjustedtimeOffReferenceId, datehours or finalBalance, externalIdSend one of hours (a delta) or finalBalance (an absolute value), never both.

Dates

Pay and time worked entries take either a date pair or a date-time pair, never both in the same entry. A date pair means whole days. This entry is one day of hourly pay:

{
  "payReferenceId": "ref:pay:acme-platform:us:hourly",
  "startDate": "2026-07-08",
  "endDate": "2026-07-08",
  "rate": "22.00",
  "hours": "8"
}

A date-time pair is a shift. Salsa converts it to UTC when it is stored, and works out the hours from the start and end times unless you send hours as well:

{
  "payReferenceId": "ref:pay:acme-platform:us:hourly",
  "startDateTime": "2026-07-08T14:00:00Z",
  "endDateTime": "2026-07-08T22:30:00Z",
  "rate": "22.00",
  "externalId": "shift-98211"
}

Benefits, deductions and time off taken take a date pair only. Accrued and balance-adjusted time off take a single date:

{
  "timeOff": {
    "taken": [
      {
        "timeOffReferenceId": "ref:timeoff:acme-platform:us:pto",
        "startDate": "2026-07-10",
        "endDate": "2026-07-10",
        "hours": "8"
      }
    ],
    "accrued": [
      {
        "timeOffReferenceId": "ref:timeoff:acme-platform:us:pto",
        "date": "2026-07-12",
        "hours": "3.08"
      }
    ]
  }
}

There is no separate "effective date". The entry's own dates are what a payroll run matches against its pay period.

Send hours day by day or shift by shift. Salsa calculates overtime and premiums from a per-day record of hours, so an entry that spans several days is paid but cannot be placed on a day. See Which hours count.

How the amount is derived

A pay entry's amount comes from whichever of these the pay type's amount strategy allows:

You sendSalsa pays
amountThat fixed amount. Any rate or hours in the entry are ignored. Use it for tips, bonuses, flat commissions.
rate and hoursRate times hours.
rate and a date-time pair, no hoursRate times the hours in the shift. Send hours as well to pay fewer hours than the shift spans, for example to exclude an unpaid break.
rate and itemsPiece rate.
rate and frequency (WEEKLY, MONTHLY, YEARLY)A salary prorated to the pay period.

Sending a combination the pay type does not allow rejects the whole request. The response identifies which required amount fields are missing or incompatible.

Types, not policies

payReferenceId, benefitReferenceId, deductionReferenceId and timeOffReferenceId accept a partner type reference id (ref:pay:acme-platform:us:hourly), a partner type id, or an employer type id for employer-specific subtypes. They never accept a policy. A worker's pay policy sets a default rate for data entered in the payroll run UI; an entry you send carries its own rate and takes precedence over the policy. See Set up employer pay types and Payroll Elements.

External ids

externalId is your identifier for the entry: a shift id, a visit id, a timesheet line id. It must be unique per worker. Reusing an ID already assigned to another entry for that worker returns a per-worker error. External ids are what make targeted corrections possible, as described under Filters.

Period replacement

There is one way to send entries: a period replacement. You declare a period and a list of workers, and for each worker the entries that exist in your system inside that period. Salsa makes its records match yours.

{
  "data": {
    "type": "PaystreamPayrollElementPeriodReplacementInput",
    "employerId": "er_123456",
    "periodStartDate": "2026-07-06",
    "periodEndDate": "2026-07-12",
    "workers": [
      {
        "workerId": "wr_1a2b...",
        "pay": [
          {
            "payReferenceId": "ref:pay:acme-platform:us:hourly",
            "startDateTime": "2026-07-06T14:00:00Z",
            "endDateTime": "2026-07-06T22:30:00Z",
            "rate": "22.00",
            "hours": "8",
            "externalId": "shift-98211"
          },
          {
            "payReferenceId": "ref:pay:acme-platform:us:hourly",
            "startDateTime": "2026-07-07T14:00:00Z",
            "endDateTime": "2026-07-07T20:00:00Z",
            "rate": "22.00",
            "externalId": "shift-98240"
          }
        ],
        "timeOff": {
          "taken": [
            {
              "timeOffReferenceId": "ref:timeoff:acme-platform:us:pto",
              "startDate": "2026-07-10",
              "endDate": "2026-07-10",
              "hours": "8"
            }
          ]
        }
      },
      {
        "workerId": "wr_3c4d...",
        "pay": [
          {
            "payReferenceId": "ref:pay:acme-platform:us:commission",
            "startDate": "2026-07-09",
            "endDate": "2026-07-09",
            "amount": "150.00",
            "externalId": "deal-4471"
          }
        ]
      }
    ]
  }
}

What one call replaces

A call replaces a well-defined part of the data and nothing outside it:

  • Only the workers you list. A worker who is not in workers is untouched.
  • Only the entry lists you include. A list you omit, or send as null, is left alone. A list you send as [] is cleared for that worker in the period. Some SDKs serialize an empty list as null; if you need to clear, send the request directly.
  • Only entries inside the period. Existing entries whose interval overlaps the period are candidates for deletion. Every entry you send must sit entirely inside the period; otherwise the request is rejected.
  • Only what the filters allow, if you set any. See Filters.

Within those limits, Salsa compares what you sent with what it has. An entry that matches an existing one exactly, same dates, same type, same amounts, same external id, is kept as it is. Existing entries that do not match are deleted. Entries you sent that have no match are created.

THE REQUEST, AND THE ENTRIES IT ACTS ON{ "periodStartDate": "2026-07-06", "periodEndDate": "2026-07-12", "filters": { "includePayReferenceIds": ["hourly"] }, "workers": [ { "workerId": "A", "pay": [ Jul 06 7.5h, Jul 07 8h, Jul 10 8h ], "timeWorked": [ Jul 07 ] }, { "workerId": "B", "pay": [ Jul 06 8h, Jul 09 7h ], "timeWorked": [] } ] // no "benefits" list anywhere}JUL 06JUL 07JUL 08JUL 09JUL 10JUL 11JUL 12WORKER AWORKER BWORKER CHourly 8hHourly 7.5hHourly 8hHourly 6hTips $40Hourly 8hTW 8hTW 8h401k · whole periodHourly 8hHourly 6hHourly 7hTW 6hHealth · whole periodHourly 8hTW 8h401k · whole periodOne period replacement, and the entries Salsa already holds for three workers in that week. Each step lights up one part of therequest and what it decides on the board.periodStartDate and periodEndDate: only entries inside Jul 06 to 12 are candidates. Nothing outside the band can be touched bythis call.filters.includePayReferenceIds: the call only looks at hourly pay, plus the time worked list it carries. The tips entry and thebenefits are outside the filter and stay as they are, even inside the period.workers: only A and B are listed. Worker C is not in the request, so nothing of C's changes.pay for A and B: Salsa compares the list you sent with what it holds. Exact matches are kept, entries you did not resend aredeleted, new ones are created. A's Jul 06 pay is confirmed in a run, so it stays and your 7.5h version is created next to it.timeWorked: A sent Jul 07 only, so Jul 06 is deleted. B sent an empty list, which means "B has no time worked", so B's Jul 09 isdeleted.No benefits list in the request: the 401k and health entries are not part of the call at all, so they are untouched.
1 / N

In short: a period replacement changes only what its period, filter, listed workers and included lists select. A list you leave out, such as benefits, is untouched.

🚧

Without a filter, a call replaces everything in the period for the workers it lists

If you send a worker's pay for July 6 to 12 in one call, and then send the same worker's pay again in a second call that contains only one shift, the second call deletes the other shifts. A period replacement means "this is everything I have for these dates". To send data in pieces, split by period or by worker so the calls never overlap, or add a filter so a call only touches the entries it names. Sending pay in one call and benefits in another for the same workers and period is fine, because each call includes only one list.

The response lists, per worker, the entries the call created, with their Salsa ids. Entries that were already there and did not change are not listed, so a worker whose entries all matched does not appear at all. If you send the same request twice, the second response usually comes back with an empty workers list. That is not an error: nothing needed to change. A worker whose earlier entries are still waiting for a payroll run to pick them up can still appear, so that Salsa includes them in the refresh. Either way, you can safely retry a request that timed out.

Filters

filters narrow what a call replaces. They apply on top of the worker, list and period rules, and they combine with AND.

FilterThe call replaces only
includePayReferenceIdsPay entries of those pay types. Every pay type used in the call's entries must be in the list.
includeBenefitReferenceIds, includeDeductionReferenceIds, includeTimeOffReferenceIdsThe same, for benefits, deductions and time off.
includeExternalIdsEntries with those external ids. Every entry in the call must carry one of the listed ids, and an id in the filter that is missing from the payload is deleted.

includeExternalIds is how you correct or delete a single entry without resending the rest of the period:

{
  "data": {
    "type": "PaystreamPayrollElementPeriodReplacementInput",
    "employerId": "er_123456",
    "periodStartDate": "2026-07-06",
    "periodEndDate": "2026-07-06",
    "filters": {
      "includeExternalIds": ["shift-98211"]
    },
    "workers": [
      {
        "workerId": "wr_1a2b...",
        "pay": [
          {
            "payReferenceId": "ref:pay:acme-platform:us:hourly",
            "startDateTime": "2026-07-06T14:00:00Z",
            "endDateTime": "2026-07-06T22:30:00Z",
            "rate": "22.00",
            "hours": "7.5",
            "externalId": "shift-98211"
          }
        ]
      }
    ]
  }
}

To delete that shift instead, keep the filter and send "pay": [].

Shifts at the edge of a period

Every entry must lie inside the declared period after its times are converted to UTC. A shift that starts at 22:00 local time on the last day of the period and ends after midnight falls outside a period that ends that day. Either extend the period you declare to cover the next day, or split the shift at the boundary and send two entries. Splitting is the safer choice when the two days belong to different pay periods: an entry that overlaps two pay periods is picked up by whichever run is created first, and a later replacement of the second period would delete it if the first run is not yet confirmed.

Overlapping entries

Two entries for the same worker and the same pay type whose intervals overlap, or even touch, return an error. Overlapping timeWorked entries are always rejected. If a worker has two things happening at once, two jobs at different rates for example, use two pay types, or ask Salsa to enable overlapping entries for that pay type; the hours are then merged before overtime is evaluated. See Overlapping shifts.

From entry to payroll run

YOUR ENTRYPAYROLL RUN · JUL 06 → JUL 12HOURLY PAY · 8h · $22.00 · Jul 06externalId shift-98211PENDINGPROCESSED · DRAFTCONFIRMED · LOCKEDvalues updated by your resendHOURLY PAY · 7.5h · $22.00 · Jul 06created next to the confirmed onePENDING · WAITS FOR THE NEXT RUNDRAFT · calculatedloaded every pending entry inside Jul 06 to 12and marked them processedrefreshed after your resend: old values dropped,new values processedDRAFT · deletedits entries go back to pendingCONFIRMEDits entries are locked; replacements never touch themthe new entry is not in this run;the next run covering Jul 06 will pay it in fullYou send the entry. It is stored as pending: nothing has consumed it yet.The employer opens or Salsa schedules a draft run for Jul 06 to 12. Calculating the draft loads every pending entry inthe period and marks it processed. A preview of the same dates now reads nothing: previews only see pending entries.You resend the period with a changed value. The old entry is replaced, and Salsa refreshes the pending draft so itprocesses the new one. Confirmed runs are never refreshed.The draft is deleted. Its entries are released and become pending again, ready for the next run that covers them.The employer confirms the run. Entries it processed are locked for good: a period replacement will not delete orchange them.You resend Jul 06 with a different value after confirmation. The locked entry stays; the new one is created beside it,pending, and the next run covering Jul 06 pays it in full. Send only the difference.
1 / N

In short: an entry is pending until a draft run processes it. A resend replaces it and refreshes the draft; confirming the run locks it, and a resend after that creates a second pending entry that the next run pays in full.

An entry starts as pending. When a payroll run is calculated, it loads every pending entry whose interval overlaps its pay period for its workers, and marks them processed. This happens when a draft is calculated, not when the run is confirmed. A processed entry belongs to that run until the run is refreshed, deleted, or confirmed.

That one rule explains the most common surprise, "I sent entries and the run shows nothing":

  • An earlier draft, a hidden draft, or an overlapping off-cycle draft already processed the entries. Previews only read pending entries. Deleting that draft releases its entries; cancelling a confirmed run does not.
  • The entries are dated before the worker's contract start, or the worker was terminated before the period. Payroll runs skip both.
  • The entries are outside the run's pay period.
  • An off-cycle run was created without including that element type.

Salsa refreshes open payroll runs for you

When a period replacement writes or deletes entries, Salsa finds pending payroll runs that cover those dates and queues a refresh for the workers you sent. The call returns after the entries are written; within moments, the updated draft reflects the new data without the employer re-entering anything. If an entry the run relied on was removed, the run shows the relevant alert.

A burst of calls for the same employer collapses into one refresh. This background mode is the recommended default. Partners can also call the refreshPayrollRun GraphQL mutation when they need to request a refresh on demand.

Only pending runs are ever refreshed. Changes to a worker's readiness or year-to-date figures do not trigger a refresh; only new or deleted entries do.

After a run is confirmed

For a correction after a run is confirmed, send only the difference: a positive or negative entry for the amount that was missing or overpaid, dated inside the next period or an off-cycle run. If you cannot express the correction as an entry, contact Salsa support before the next run is confirmed.

Integration patterns

Partners integrate Paystream in two shapes. Both are fully supported; pick the one that matches how your platform produces data.

Per event

Send one call each time something happens: a shift is closed, a visit is completed, a commission is approved. The call carries one worker, one or two entries, a period equal to the event's day, and includeExternalIds with the event's id. A correction later resends the same external id with new values; a cancellation sends the id with an empty list, as described in Filters. These calls are small, fast, and never approach any limit.

{
  "data": {
    "type": "PaystreamPayrollElementPeriodReplacementInput",
    "employerId": "er_123456",
    "periodStartDate": "2026-07-06",
    "periodEndDate": "2026-07-06",
    "filters": {
      "includeExternalIds": ["shift-98211"]
    },
    "workers": [
      {
        "workerId": "wr_1a2b...",
        "pay": [
          {
            "payReferenceId": "ref:pay:acme-platform:us:hourly",
            "startDateTime": "2026-07-06T14:00:00Z",
            "endDateTime": "2026-07-06T22:30:00Z",
            "rate": "22.00",
            "externalId": "shift-98211"
          }
        ]
      }
    ]
  }
}

Send employer and work site items only when something about them changed. Pairing an employer upsert with every shift multiplies the cost of each call for no benefit.

Per employer, on a schedule

Send one call per employer each day, or each pay period, with every worker and everything that changed. These calls are larger and a single unfiltered call replaces the whole period for every worker listed, so the export must be complete for that period. When an export grows large, split it, as described under Splitting a large export.

Answering the awaiting-input webhook

Whichever shape you use, Salsa tells you when a payroll run needs data. Subscribe to PayrollRun.awaitingInput: it carries the employer, the list of worker ids, and the pay period start and end dates. Reply with one period replacement for exactly that period and those workers, with everything you have for them. Salsa refreshes the draft, and PayrollRun.statusChanged tells you when the employer confirms it.

YOUR PLATFORMSALSAWebhook endpointreceives eventsPaystream clientsends period replacementsPAYROLL RUN · DRAFTCONFIRMEDJUL 06 → JUL 12 · 2 workersentries: 03 (refreshed)Hourly pay · 8h · Jul 06 · wr_1a2bCommission · $150 · Jul 09 · wr_3c4d1 · PayrollRun.awaitingInputemployerId · workerIds[2] · payPeriod Jul 06 → 122 · POST /paystream/payroll-elementsperiod Jul 06 → 12 · same 2 workers · all entries3 · Salsa refreshes the draft4 · PayrollRun.statusChanged · CONFIRMED

In short: Salsa sends PayrollRun.awaitingInput, you answer with one period replacement for that period and those workers, Salsa refreshes the draft, and PayrollRun.statusChanged is sent when the employer confirms.

{
  "eventType": "PayrollRun.awaitingInput",
  "data": {
    "employerId": "er_123456",
    "employerExternalId": "acct-3391",
    "workerIds": ["wr_1a2b...", "wr_3c4d..."],
    "payPeriod": {
      "startDate": "2026-07-06",
      "endDate": "2026-07-12"
    }
  }
}

The event fires when a pay group's next period is scheduled, and again as a reminder before the submission cutoff. It does not fire for off-cycle runs. You do not have to wait for it: anything sent earlier is already there when the run is calculated. Sending data as it happens, and answering the webhook with a final full replacement, is the combination that gives employers the fewest surprises.

Splitting a large export

A period replacement can be split three independent ways, and none of them interferes with the others:

  • By period. One call per day, week or pay period. A call only touches entries inside its own period, so non-overlapping periods for the same worker never conflict.
  • By workers. The same period, the worker list in groups. A worker who is not in a call is untouched.
  • By entry type. Pay in one call and benefits, deductions or time off in another, for the same period and workers. Each call includes only its own lists, so it replaces only those. If two calls must both carry pay, add includePayReferenceIds to each so they replace different pay types.
BY PERIODcall 1 = week 1 · call 2 = week 2WEEK 1WEEK 2WEEK 3WORKER AWORKER BWORKER CWORKER DBY WORKERScall 1 = workers A, B · call 2 = workers C, DWEEK 1WEEK 2WEEK 3WORKER AWORKER BWORKER CWORKER DBY ENTRY TYPEcall 1 = pay · call 2 = benefits and time offWEEK 1WEEK 2WEEK 3WORKER AWORKER BWORKER CWORKER Dcall 1 ownscall 2 ownsuntouchedEach call only replaces the slice it names. The slices never overlap, so the calls never delete each other's entries.

In short: split a large export by period, by workers or by entry type. Each call touches only its own slice.

Request limits

LimitValue
Request body10 MB
Items in data per request100
Workers per period replacement500
Employers per request10
Entries per period replacement, all lists summed5,000

A request over an application limit is rejected before anything is written, and the response identifies the limit and count. A body over 10 MB is rejected at the edge. For scale, a full month of daily pay and time worked for 70 workers is about 5,000 entries. Requests near the limits take several seconds; keep them well under one minute.

Errors

The whole request is rejected and nothing is written when:

ConditionCause
Required period dates are missingNo period.
Period start is after period endInvalid period.
An entry has an incomplete or mixed date pairNo date pair, or both date and date-time pairs.
An entry falls outside the replacement period after UTC conversionInvalid entry period.
A worker is unknown or belongs to another partnerInvalid worker.
A pay type is unknownInvalid payReferenceId.
A benefit, deduction or time off type is unknownInvalid reference id.
An employer-specific type belongs to another employerThe types in the request must match the employer.
The request includes workers from different employersInvalid worker set.
Two pay entries of the same type overlap for a workerOverlapping entries.
Amount fields do not match the pay type's strategyInvalid amount inputs.
A premium flag is sent on a date-only entryPremiums require a shift.
A work location is sent without the wage-parity flagInvalid work-location input.

The request returns 201 and errors names the workers that failed when, for one worker, an external id is already in use, or the run refresh collided with another action. Other workers in the same call are written normally.

A replacement that would delete or change entries already processed by a payroll run that does not refresh automatically is rejected with a validation error. Entries in a confirmed run are never replaced; see After a run is confirmed.

For hours-related validation messages, see Paystream validation errors related to hours.

Related pages


Did this page help you?