Handle Webhooks
This guide walks through implementing a webhook receiver for Worksome events. By the end, you will have a working endpoint that verifies webhook signatures, processes event payloads, and responds correctly.
For an overview of webhook concepts (payloads, signatures, retries), see the Introduction to Webhooks.
Setting up your endpoint
Create an HTTP endpoint that accepts POST requests. Worksome sends all webhooks as POST requests with a JSON body and a Signature header.
Your endpoint must:
- Read the raw request body (do not parse it before verifying the signature).
- Verify the HMAC signature using your shared secret.
- Parse the JSON payload and route it by event type.
- Respond with a
2XXstatus code to acknowledge receipt.
Verifying the signature
Every webhook includes a Signature header containing an HMAC-SHA256 hash of the raw request body, signed with your shared secret. Always verify this before processing the payload.
PHP
function verifyWebhookSignature(string $payload, string $signature, string $secret): bool { $expected = hash_hmac('sha256', $payload, $secret); return hash_equals($expected, $signature); } // In your controller: $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_SIGNATURE'] ?? ''; $secret = getenv('WORKSOME_WEBHOOK_SECRET'); if (! verifyWebhookSignature($payload, $signature, $secret)) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } $event = json_decode($payload, true);
Node.js
const crypto = require('crypto'); function verifyWebhookSignature(payload, signature, secret) { if (typeof signature !== 'string' || signature.length === 0) { return false; } const expected = crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); const expectedBuffer = Buffer.from(expected); const receivedBuffer = Buffer.from(signature); // timingSafeEqual throws on length mismatch — guard explicitly so the // handler fails closed instead of crashing on a malformed signature. if (expectedBuffer.length !== receivedBuffer.length) { return false; } return crypto.timingSafeEqual(expectedBuffer, receivedBuffer); } // In your Express handler: app.post('/webhooks/worksome', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['signature']; const secret = process.env.WORKSOME_WEBHOOK_SECRET; // express.raw() gives a Buffer. Decode it once and verify the signature // against the exact bytes received, before parsing anything. const payload = req.body.toString('utf8'); if (!verifyWebhookSignature(payload, signature, secret)) { return res.status(401).json({ error: 'Invalid signature' }); } const event = JSON.parse(payload); // Process the event... res.sendStatus(200); });
Python
import hmac import hashlib def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)
Warning
Always use constant-time comparison functions (hash_equals in PHP, timingSafeEqual in Node.js, compare_digest in Python) to prevent timing attacks.
Routing events
After verifying the signature, parse the JSON and route based on the event field.
$event = json_decode($payload, true); switch ($event['event']) { case 'contractAccepted': handleContractAccepted($event['data']); break; case 'hireUpdated': handleHireUpdated($event['data']); break; case 'hireCancelled': handleHireCancelled($event['data']); break; case 'hireEnded': handleHireEnded($event['data']); break; case 'hireTerminated': handleHireTerminated($event['data']); break; case 'trustedContactUpdated': handleTrustedContactUpdated($event['data']); break; default: // Log unknown events for monitoring error_log("Unknown webhook event: " . $event['event']); break; }
Tip
Always return 200 even for unknown event types. This prevents Worksome from retrying events your system does not handle and allows us to add new event types without breaking your integration.
Processing the payload
Each event’s data object contains the relevant entities. For example, the contractAccepted event includes the contract, worker, and trustedContact objects, plus a customFieldValues array.
function handleContractAccepted(array $data): void { $contract = $data['contract']; $worker = $data['worker']; // Sync the contract to your system $yourContract = YourContract::updateOrCreate( ['worksome_id' => $contract['id']], [ 'job_title' => $contract['jobName'], 'start_date' => $contract['startDate'], 'end_date' => $contract['endDate'], 'rate' => $contract['rate'], 'currency' => $contract['currency'], 'status' => $contract['hireStatus'], ] ); // Sync worker details $yourWorker = YourWorker::updateOrCreate( ['worksome_id' => $worker['id']], [ 'name' => $worker['firstName'] . ' ' . $worker['lastName'], 'email' => $worker['email'], ] ); }
See the Event Reference for full payload schemas and field descriptions for each event type.
Responding correctly
Worksome expects a 2XX response to confirm delivery. Any other status triggers the retry process.
| Response | Worksome behavior |
|---|---|
200–299 |
Delivery confirmed. No retries. |
401 |
Signature mismatch. Retries will follow. |
4XX (other) |
Client error. Retries will follow. |
5XX |
Server error. Retries will follow. |
| Timeout (>60s) | Treated as failure. Retries will follow. |
Tip
Respond quickly — ideally within a few seconds. If your processing takes longer, acknowledge the webhook with 200 immediately and process it asynchronously (e.g., via a queue).
Handling retries
If your endpoint fails to respond with 2XX, Worksome retries with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 2nd | 10 seconds |
| 3rd | 100 seconds |
| 4th | 1,000 seconds (~17 minutes) |
| 5th | 10,000 seconds (~2.8 hours) |
After 5 failed attempts, Worksome stops retrying. Contact support if you suspect missed webhooks.
Idempotency: Your handler should be idempotent — processing the same webhook twice should produce the same result. Use the entity IDs in the payload to detect duplicates.
Security best practices
- Verify every request. Never process a webhook without checking the signature first.
- Use HTTPS. Your endpoint URL should always use HTTPS to protect the payload in transit.
- Keep your secret safe. Store the webhook secret in environment variables, not in source code.
- Rotate secrets periodically. Coordinate with Worksome support to rotate your webhook secret on a schedule.
- Restrict access. If possible, allowlist Worksome’s IP ranges at the network level.
Testing locally
During development, use a tunneling service to expose your local server to the internet:
# Using ngrok ngrok http 8080 # Then provide the ngrok URL to Worksome as your webhook endpoint: # https://abc123.ngrok.io/webhooks/worksome
Warning
Local tunneling tools are for development only. Always use a production-grade endpoint for live integrations.
Complete example (Laravel)
namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\Response; class WorksomeWebhookController extends Controller { public function handle(Request $request): Response { $payload = $request->getContent(); $signature = $request->header('Signature', ''); $secret = config('services.worksome.webhook_secret'); // 1. Verify signature $expected = hash_hmac('sha256', $payload, $secret); if (! hash_equals($expected, $signature)) { return response()->json(['error' => 'Invalid signature'], 401); } // 2. Parse and route $event = json_decode($payload, true); match ($event['event']) { 'contractAccepted' => $this->onContractAccepted($event['data']), 'hireUpdated' => $this->onHireUpdated($event['data']), 'hireCancelled' => $this->onHireCancelled($event['data']), 'hireEnded' => $this->onHireEnded($event['data']), 'hireTerminated' => $this->onHireTerminated($event['data']), 'trustedContactUpdated' => $this->onTrustedContactUpdated($event['data']), default => logger()->info("Unhandled webhook: {$event['event']}"), }; // 3. Acknowledge return response()->noContent(); } private function onContractAccepted(array $data): void { // Example: sync the new contractor to your HRIS $worker = $data['worker']; $contract = $data['contract']; $trustedContact = $data['trustedContact']; YourHRIS::createContractorRecord([ 'external_id' => $trustedContact['externalIdentifier'] ?? $contract['id'], 'name' => $worker['firstName'] . ' ' . $worker['lastName'], 'email' => $worker['email'], 'job_title' => $contract['jobName'], 'start_date' => $contract['startDate'], 'end_date' => $contract['endDate'], 'rate' => $contract['rate'], 'currency' => $contract['currency'], ]); } private function onHireUpdated(array $data): void { // Process hire update... } private function onHireCancelled(array $data): void { // Process hire cancellation... } private function onHireEnded(array $data): void { // Process hire end... } private function onHireTerminated(array $data): void { // Example: close the contractor assignment in your HRIS $contract = $data['contract']; $externalId = $data['trustedContact']['externalIdentifier'] ?? $contract['id']; YourHRIS::closeAssignment($externalId, [ 'termination_date' => $contract['endDate'], 'reason' => $data['terminatedReason'], ]); } private function onTrustedContactUpdated(array $data): void { // Process trusted contact update... } }
Remember to register the route without CSRF protection, since webhooks come from an external source:
// routes/api.php Route::post('/webhooks/worksome', [WorksomeWebhookController::class, 'handle']);
Real-world integration patterns
Webhooks are the backbone of real-time integrations with Worksome. Here are the most common patterns:
HRIS sync (contract accepted → create contractor record)
The most common webhook integration is pushing contractor records into an HR system when a contract is accepted. This eliminates manual data entry and ensures your workforce management system reflects current engagements.
What to sync on contractAccepted:
- Worker name, email, and contact details from the
workerandtrustedContactobjects - Contract terms (job title, start/end dates, rate, currency) from the
contractobject - Your internal reference from
trustedContact.externalIdentifier(if you set one when creating the hire)
What to sync on hireTerminated or hireEnded:
- Close the assignment record in your HRIS
- Update the termination/end date
What to sync on hireUpdated:
- Detect contract extensions (end date changes)
- Detect rate changes for updated contracts
Compliance and document workflows
Use contractAccepted to trigger compliance workflows — for example, sending an NDA or background check request as soon as a contractor signs. The webhook payload includes the worker’s details, so you can pre-fill document templates automatically.
Finance system reconciliation
While invoices don’t have a dedicated webhook, you can combine the hireUpdated event with periodic API polling to keep your finance system up to date — query the invoices field on the API for recent records.