https://payroll-api.getpenfold.dev/v4
Unified API for payroll partners to onboard employers, manage employees, contributions, and file uploads.
Authentication
Partner integrations authenticate with Azure AD B2C using the OAuth2 client credentials grant. These endpoints are server-to-server only — there is no interactive partner sign-in and no connection to the Penfold Workplace Platform.
Onboarding
When Penfold onboards your organisation, we provide the following via a secure channel:
- Application (client) ID — your Azure AD B2C app registration
- Client secret — used with the client ID to obtain access tokens
- Webhook signing secret(s) — for verifying inbound webhook signatures (see Webhooks below)
Penfold does not issue or deliver access tokens directly. Your servers obtain short-lived tokens from Azure AD B2C using the client ID and client secret.
Calling the API
- Request an access token from the Azure AD B2C token endpoint with
grant_type=client_credentials, yourclient_id,client_secret, and the scopes configured for your application. - Send API requests with
Authorization: Bearer <access_token>.
The token's application (client) ID is mapped to your Penfold organisation, which scopes all requests to employers owned by your organisation only.
Example token request (substitute client_id, client_secret, and scope):
curl --location --request POST 'https://login.microsoftonline.com/36d1ec63-a7dc-48ca-a634-5514be9a63dd/oauth2/v2.0/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=<client_id>' \
--data-urlencode 'scope=https://penfoldstaging.onmicrosoft.com/<client_id>/.default' \
--data-urlencode 'client_secret=<client_secret>'
Submission lifecycle (uploads)
Employee enrolment (POST /employers/{employer_id}/employees) and contribution
(POST /employers/{employer_id}/contributions) submissions are asynchronous. Each
returns an Upload object representing queued work, not a final result.
To consume the result:
- Poll
GET /uploads/{upload_id}untilstatusis terminal (Processed,Error,Timeout, orPartiallyProcessed). - Fetch created enrolments via
GET /uploads/{upload_id}/enrolmentsand contributions viaGET /uploads/{upload_id}/contributions. Member and contribution IDs are not returned in the submission response. - Fetch errors via
GET /uploads/{upload_id}/errors. Errors are scoped at File, AllRows or Row level.
Processing is idempotent per pay period: re-submitting the same records (or file) will
only process records that have not already been processed successfully. This applies to
both contribution and enrolment submissions, and is the recommended way to recover from
a PartiallyProcessed upload.
Webhooks
Penfold POSTs a JSON body to your pre-registered webhook URL when a resource reaches a notable state. Every webhook shares a common envelope:
| Field | Type | Description |
|---|---|---|
event_id |
string | Globally unique. Upsert by this value for idempotency. |
resource_type |
string | Which resource changed (e.g. EmployerSigningRequest). Determines valid action values and payload shape. |
action |
string | What happened (Created, Completed, Cancelled, Failed). |
occurred_at |
string | ISO 8601 UTC timestamp when Penfold recorded the event. |
payload |
object | Resource-specific data — see schemas below. |
Resource types
EmployerSigningRequest
Emitted when an agreement signing session is created or reaches a terminal outcome.
Expect one Created event followed by exactly one of Completed, Cancelled, or Failed.
Actions: Created, Completed, Cancelled, Failed
Payload:
| Field | Type | Description |
|---|---|---|
signing_session_id |
string | Same id returned from create-session. |
employer_id |
string | Penfold employer id. |
employer_external_reference |
string | The employer's external reference. |
MemberSchemeStatus
Emitted when a worker's scheme membership status changes (e.g. opt-out via the Penfold member flow, irregular contributor, employer scheme closed).
Actions: Changed
Payload: employer_id, employee_id, previous_status, status, effective_at
Employment
Emitted when a worker's employment ends, whether initiated by the partner or by Penfold.
Actions: Ended
Payload: employer_id, employee_id, exit_date, initiated_by (Partner or Penfold)
ContributionChangeRequest
Emitted after a successful POST .../contribution_change_requests.
Actions: Created
Payload: employer_id, employee_id, id, employee_contributions_percent,
salary_sacrifice_enabled, created_at
Worker opt-out
There is no partner API to opt a worker out. Workers opt out via the Penfold member
flow (email / member app). Penfold updates member_scheme_status to OptedOut and
emits a MemberSchemeStatus webhook with action Changed. Partners may also poll
GET .../employees/{employee_id} as a fallback.
Signature verification (X-Penfold-Signature)
Penfold signs every webhook with HMAC-SHA256 using a shared secret provisioned during onboarding.
- Read the
X-Penfold-Signatureheader. It contains comma-separated key/value pairs:t(Unix timestamp in seconds when Penfold sent the request) andv1(lowercase hex-encoded HMAC-SHA256). - Construct the signing payload (UTF-8):
{t}.{raw_body}— the string value oft, a literal., then the raw JSON body bytes. - Compute
HMAC-SHA256(secret, signing_payload), hex-encode, and compare tov1in constant time. - Reject if
|now_unix - t| > 300(5-minute replay window). - Upsert by
event_idfor idempotency.
Delivery semantics
- Return
2xxwithin 30 seconds. - On non-2xx, timeout, or no response: exponential backoff starting at 60 seconds, doubling each attempt, max interval 3600 seconds, up to 10 attempts within 24 hours.
- After exhausted retries, events are not retried automatically.
Naming conventions
This API uses one casing scheme for what partners send and receive — JSON bodies, URL path and query parameters, pagination fields, and signing redirect parameters. Penfold's internal code and databases are not affected.
Default: Identifiers use snake_case (e.g. employer_id, page_size,
signing_session_id). Values that describe state or type (statuses, payment methods,
webhook actions, upload purpose, signing redirect result values such as Success, etc.)
use PascalCase. Values passed to sort_by are field names and use snake_case
(e.g. created_at).
New fields should follow these rules unless listed as an exception below.
Exceptions
| Item | Casing | Reason |
|---|---|---|
Azure AD token request (grant_type, client_id, client_secret, scope) |
OAuth2 protocol names (typically snake_case) |
Field names are defined by OAuth2, not this API. Partners call Microsoft's token endpoint before calling us. |
HTTP header names (Authorization, Content-Type, X-Penfold-Signature) |
Standard HTTP header casing | Not JSON body fields |
Webhook signature header subkeys (t, v1) |
Lowercase | Fixed format documented under Webhooks |
sort_order query values (asc, desc) |
Lowercase | Accepted case-insensitively |
Upload error code and scope values |
PascalCase |
Fixed error type labels (e.g. NationalInsuranceNumberInvalid, AllRows), not field names |
Contribution *_amount fields |
Decimal pounds | Historical payroll API convention for pay-period contributions; unchanged |
Worker monetary *_pence fields |
Integer pence | Portfolio summary, transactions, and pot performance expose whole pence |
| Primary id on new resource response bodies | id |
Path params may use qualified names (e.g. fund_allocation_id); the returned resource uses id |
This is version 4.0 of this API documentation. Last update on Jun 22, 2026.