How to authenticate calls to the Webhooks API and how to verify that a webhook delivery came from Paylocity.
Paylocity webhooks use two separate authentication methods, depending on how your application is interacting with Paylocity:
- Calling the Webhooks API: Your application uses an OAuth 2.0 bearer token to make requests to Paylocity, such as creating or managing webhook subscriptions.
- Receiving webhook events: Paylocity uses HMAC-SHA256 to sign webhook deliveries sent to your endpoint. Your application verifies the signature to confirm that the request came from Paylocity and has not been modified.
In short:
Your application → Paylocity: OAuth 2.0
Paylocity → Your application: HMAC-SHA256
Calling the Webhooks API
All Webhooks API endpoints require an OAuth 2.0 bearer token.
Webhooks use the same OAuth 2.0 Client Credentials flow as other Paylocity APIs. See Authentication in API Basics for instructions on obtaining and using an access token.
Include the token in the Authorization header of each Webhooks API request:
Authorization: Bearer TOKEN_GOES_HERE
Securing webhook deliveries with HMAC
When Paylocity sends a webhook event to your endpoint, your application needs a way to verify that the request is authentic.
Paylocity uses HMAC-SHA256 signing for this purpose.
When you create a webhook subscription, you configure HMAC authentication in the subscription's auth object. Paylocity generates an HMAC secret and uses it to sign webhook deliveries sent to the subscription URL.
Your application uses that same secret to verify the signature included with each delivery.
Create an HMAC-secured subscription
When creating the subscription, set auth.type to HMAC and provide a future expiration date in hmac.key1.expirationAt.
For example:
{
"name": "Employee Updates - HMAC",
"eventTypes": ["employee.person.v1", "employee.assignment.v1"],
"url": "https://my-app.example.com/webhooks/paylocity",
"auth": {
"type": "HMAC",
"hmac": {
"key1": { "expirationAt": "2027-01-01T00:00:00Z" }
}
}
}When the subscription is successfully created, the 201 Created response includes a generated, base64-encoded HMAC secret.
Save this secret securely when you receive it. The secret is returned only once and cannot be retrieved later.
Save your HMAC secret
The HMAC secret is included only in the response when the subscription or key is created.
Subsequent requests to retrieve the subscription return information about the key, including its keyId, issuedAt, and expirationAt, but do not return the secret.
If the secret is lost, you must rotate the HMAC key and store the newly generated secret.
Verifying webhook deliveries
Each webhook delivery includes headers your application can use to verify the request:
| Header | Description |
|---|---|
x-pcty-signature | Contains the keyId and HMAC signature. If multiple keys are active during key rotation, the header contains a comma-separated signature for each active key. |
x-pcty-timestamp | Unix timestamp, in seconds, indicating when the delivery was signed. |
The signature is generated using:
HMAC-SHA256("{timestamp}.{rawBody}")
The HMAC secret returned when the key was created is base64-encoded. Your application must base64-decode the secret before using it to calculate the signature.
Verification process
When your endpoint receives a webhook:
- Read the x-pcty-timestamp header.
- Confirm that the timestamp is no more than 5 minutes old to help protect against replay attacks.
- Combine the timestamp and raw request body using the following format:
{timestamp}.{rawBody} - Base64-decode your stored HMAC secret.
- Use the decoded secret to calculate an HMAC-SHA256 signature.
- Compare the calculated signature with the signature provided in x-pcty-signature.
- Accept the webhook when a signature matches. Otherwise, reject the request.
Important: Verify the signature using the raw request body before parsing the JSON. Changes to the request body can cause signature verification to fail.
Node.js, Python, and C# examples below:
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, base64Secret, sigHeader, tsHeader) {
const timestamp = parseInt(tsHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Webhook timestamp outside acceptable window');
}
const stringToSign = `${timestamp}.${rawBody}`;
const keyBuffer = Buffer.from(base64Secret, 'base64');
const computedHex = crypto
.createHmac('sha256', keyBuffer)
.update(stringToSign, 'utf8')
.digest('hex');
return sigHeader.split(',').some(part => {
const m = part.trim().match(/keyId=[^:]+:signature=(.+)/);
return m && m[1] === computedHex;
});
}import hmac, hashlib, base64, time
def verify_webhook_signature(raw_body: bytes, base64_secret: str,
sig_header: str, ts_header: str) -> bool:
timestamp = int(ts_header)
if abs(time.time() - timestamp) > 300:
raise ValueError('Webhook timestamp outside acceptable window')
string_to_sign = f'{timestamp}.{raw_body.decode("utf-8")}'
key = base64.b64decode(base64_secret)
computed = hmac.new(key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
for entry in sig_header.split(','):
parts = dict(item.split('=', 1) for item in entry.strip().split(':'))
if parts.get('signature') == computed:
return True
return Falsepublic static bool VerifyWebhookSignature(
string rawBody, string base64Secret, string sigHeader, string tsHeader)
{
if (!long.TryParse(tsHeader, out var timestamp)) return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestamp) > 300)
throw new InvalidOperationException("Webhook timestamp outside acceptable window");
var stringToSign = $"{timestamp}.{rawBody}";
var key = Convert.FromBase64String(base64Secret);
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
var computedHex = BitConverter.ToString(hash).Replace("-", "").ToLower();
return sigHeader.Split(',').Any(entry =>
entry.Trim().Split(':')
.FirstOrDefault(p => p.StartsWith("signature="))
?.Substring("signature=".Length) == computedHex);
}Rotating HMAC keys
Webhook subscriptions support up to two active HMAC keys, key1 and key2. This allows you to rotate secrets without interrupting webhook delivery.
A typical rotation looks like this:
- Add
key2whilekey1is still active. - Securely store the new secret returned for
key2. - Update your application to verify deliveries using either active key.
- Complete the transition to
key2. - Remove key1 in a follow-up
PATCH.
While two keys are active, x-pcty-signature contains a comma-separated signature for each active key. Your application should verify the delivery against the appropriate active secret and accept the request if a valid signature matches.
For example, you can add key2 while retaining the existing key1
{
"operations": [
{
"op": "replace",
"path": "/auth",
"value": {
"type": "HMAC",
"hmac": {
"key1": { "keyId": "existing-key1-uuid-here" },
"key2": { "expirationAt": "2027-06-01T00:00:00Z" }
}
}
}
]
}HMAC key expiration dates can be set for no more than one year from the time the key is created or updated.