https://payroll-api.getpenfold.dev/v4
Unified API for onboarding employers and managing employees, contributions, pay periods and file uploads.
Authentication
Penfold provisions your integration as an organisation on this API. There are two organisation types, and the type you are given determines the OAuth2 flow you use, the scope of your access tokens, and which endpoints you may call — see Organisation types and endpoint availability below.
| Organisation type | OAuth2 flow | Token scope |
|---|---|---|
| Payroll | Authorization Code with PKCE (Amazon Cognito) | A single employer, who grants consent through the Penfold Workplace Platform |
| Partner | Client credentials (Azure AD B2C) | Every employer owned by your organisation |
Whichever flow you use, send API requests with Authorization: Bearer <access_token>
over HTTPS, and store access and refresh tokens securely server-side.
Payroll organisations — Authorization Code with PKCE
An employer admin grants your application access through the Penfold Workplace
Platform. The resulting access token carries an employer claim, so every request is
scoped to that one employer; repeat the flow once per employer you integrate with.
The full flow — environment URLs, PKCE code verifier and challenge, worked examples and token refresh — is in the integration guide. Two points are worth highlighting:
- The
redirect_urisent to/oauth2/authorize, and repeated unchanged when exchanging the code for tokens, is always Penfold's consent URL (https://platform.getpenfold.dev/auth/oauth2-consenton staging) — not your own callback URL. - Your callback URL is carried in the
stateparameter asredirectUri, and must be pre-registered against your organisation. If it is absent or unregistered, Penfold redirects to the first URL registered for your organisation rather than failing.
Partner organisations — client credentials
Server-to-server only — there is no interactive sign-in and no connection to the Penfold Workplace Platform. The token's application (client) ID is mapped to your Penfold organisation, which scopes all requests to employers owned by your organisation.
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, with
grant_type=client_credentials and the scopes configured for your application.
Example token request (substitute client_id, client_secret, and scope):
TOKEN_URL='https://login.microsoftonline.com/36d1ec63-a7dc-48ca-a634-5514be9a63dd/oauth2/v2.0/token'
curl --location --request POST "$TOKEN_URL" \
--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>'
Organisation types and endpoint availability
Most endpoints are available to both organisation types. The endpoints below are
available to Partner organisations only — a Payroll organisation calling one
receives 405 Method Not Allowed. Each is also marked in its own description.
| Method | Path |
|---|---|
POST |
/employers |
PATCH |
/employers/{employer_id} |
POST |
/employers/{employer_id}/agreement-signing-sessions |
GET |
/employers/{employer_id}/agreement-signing-sessions/{signing_session_id} |
POST |
/employers/{employer_id}/direct-debit-sessions |
GET |
/employers/{employer_id}/pay_periods |
GET |
/employers/{employer_id}/pay_periods/{pay_period_id} |
GET |
/employers/{employer_id}/employees/{employee_id}/portfolio_summary |
GET |
/employers/{employer_id}/employees/{employee_id}/pot_value_history |
GET |
/employers/{employer_id}/employees/{employee_id}/transactions |
GET |
/employers/{employer_id}/employees/{employee_id}/beneficiaries |
PUT |
/employers/{employer_id}/employees/{employee_id}/beneficiaries |
GET |
/employers/{employer_id}/employees/{employee_id}/fund_allocation |
PUT |
/employers/{employer_id}/employees/{employee_id}/fund_allocation |
GET |
/fund_allocations |
GET |
/fund_allocations/{fund_allocation_id} |
Two further differences apply to endpoints that both organisation types may call:
external_idon employee enrolment rows (POST .../employees) and contribution rows (POST .../contributions) is required for Partner organisations that use single sign-on with Penfold, and must be omitted by every other organisation, including Partner organisations that do not use single sign-on.GET /employersreturns every employer owned by your organisation for a Partner organisation; for a Payroll organisation it returns only the single employer your access token is scoped to.
Date fields
Date-only fields (format: date, e.g. exit_date, employment_start_date,
date_of_birth, pay-period dates) use UTC calendar dates in YYYY-MM-DD
form. Values are interpreted and emitted as the UTC calendar day, not the
server's local timezone. Timestamps (format: date-time, e.g. created_at)
remain ISO 8601 UTC instants.
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. worker-initiated opt-out, 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 you 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
FundAllocation
Emitted when a worker's elected fund allocation changes via a successful
PUT .../employees/{employee_id}/fund_allocation (not on idempotent no-ops).
Optional — you may rely on the PUT 200 response instead if you originated the
request.
Actions: Changed
Payload: employer_id, employee_id, previous_fund_allocation_id,
fund_allocation_id, effective_at
EmployerAmlVerificationStatus
Emitted when an employer company's AML verification status changes (not on idempotent no-ops). You may also poll the employer endpoints as a fallback.
Actions: Changed
Payload: employer_id, previous_status, status
Worker opt-out
There is no API to opt a worker out. Workers opt out through Penfold
worker self-service (for example email). Penfold updates member_scheme_status to OptedOut and
emits a MemberSchemeStatus webhook with action Changed. You 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 you 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, and are sent to the identity provider's token endpoint rather than to 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 |
Support
- Published documentation: https://docs.getpenfold.dev
- Support: api@getpenfold.com
This is version 4.0 of this API documentation. Last update on Sep 30, 2026.