Agent skill
Greenshades Webhooks Skill
Receive and verify Greenshades webhooks. Use when setting up Greenshades payroll or HR webhook handlers, debugging X-GS-SIGNATURE verification, or handling payRun.completed, employee.updated and other subscription events.
Install this skill
npx skills add hookdeck/webhook-skills --skill greenshades-webhooks
When to Use This Skill
- How do I receive Greenshades webhooks?
- How do I verify the
X-GS-SIGNATUREheader on a Greenshades webhook? - Why does my Greenshades webhook signature verification fail?
- How do I handle
payRun.completedoremployee.updatedevents? - How do I create a Greenshades webhook subscription with an HMAC key?
How Greenshades Signs Webhooks
Greenshades signs each delivery with HMAC-SHA256 and the hmacKey you chose when creating the subscription (POST /webhooks/subscriptions). Two headers arrive on every request:
| Header | Value |
|---|---|
X-GS-TIMESTAMP | The timestamp used for the hash (format not documented) |
X-GS-SIGNATURE | Bare lowercase hex HMAC-SHA256 digest, no prefix |
The signed message is the X-GS-TIMESTAMP value, one newline (a real LF, 0x0A), then the raw request body bytes. The key is the secret's UTF-8 bytes, used as-is.
Verification (core)
const crypto = require('crypto');
function verifyGreenshades(rawBody /* Buffer */, timestamp, signature, secret) {
if (!timestamp || !signature || !secret) return false;
if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding
const message = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('\n'), rawBody]);
const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(message).digest();
const received = Buffer.from(signature.toLowerCase(), 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
import hashlib, hmac, re
def verify_greenshades(raw_body: bytes, timestamp, signature, secret) -> bool:
if not timestamp or not signature or not secret:
return False
if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
return False
message = timestamp.encode("latin-1") + b"\n" + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).digest()
return hmac.compare_digest(bytes.fromhex(signature), expected)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Gotchas:
- Build the message as bytes. Never decode the body to a string and re-encode it, and never
JSON.parse/JSON.stringifybefore verifying. In Express useexpress.raw({ type: '*/*' }); in Next.jsawait request.arrayBuffer(); in FastAPIawait request.body(). - The separator is one real newline byte, not the two characters backslash and
n. - Greenshades' C# recipe encodes key and content as ASCII; Hookdeck and these examples sign the UTF-8 body bytes. The two only differ for a non-ASCII body.
- The recipe's "Response Example" hash uses placeholder inputs and is not a test vector.
- Missing or empty header, malformed digest or mismatch: respond
401. - No timestamp window by default. Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck applies no tolerance, so a fixed window would reject legitimate retries. The examples offer an opt-in
GREENSHADES_(unset orWEBHOOK_ TOLERANCE_ SECONDS 0= off); because theX-GS-TIMESTAMPformat is undocumented, it is parsed only when you opt in.
HTTP Method
Greenshades' docs don't name the HTTP method used for deliveries. Hookdeck's Greenshades source pre-selects POST without managing the method, so if the sender uses another method, allow it on the source too. The examples accept GET, POST, PUT, PATCH and DELETE and verify the signature first for every method, treating a missing body as empty bytes.
Events
Subscribe to these event names (from the WebhookEvent list of the create-subscription API):
| Event | Area |
|---|---|
payRun.completed | Pay run |
payRun.costReportingTransactions.completed | Pay run |
payRun.payRecord.voided | Pay run |
payRun.payRecords.completed | Pay run |
paySchedule.updated | Pay schedule |
employee.added | Employee |
employee.updated | Employee |
employee.directDeposit.updated | Employee |
employee.paySchedule.updated | Employee |
employee.earningCode.added | Employee earning code |
employee.earningCode.updated | Employee earning code |
employee.earningCode.deleted | Employee earning code |
No delivery body is documented, so which field carries the event name is unknown. The examples read it defensively (eventName, event, type or name) and mark that code as illustrative; check a real delivery. No delivery, retry or timeout policy is documented either.
Environment Variables
GREENSHADES_WEBHOOK_SECRET=your_hmac_key_here # the hmacKey set on the subscription
GREENSHADES_WEBHOOK_TOLERANCE_SECONDS=300 # optional; unset or 0 = no timestamp check
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 greenshades --path /webhooks/greenshades
Reference Materials
- references/overview.md - What Greenshades webhooks are, events, payloads
- references/setup.md - Create a subscription, choose the HMAC key, point it at your endpoint
- references/verification.md - Signature details, byte handling and debugging