This guide explains how to set up your system to receive webhooks from Skyplanner.
Overview #
Skyplanner sends HTTP POST requests to your configured endpoint whenever certain events occur. These webhooks allow your external systems to react in real-time to changes in Skyplanner.
Webhook Events #
Skyplanner can send webhooks for the following events:
| JOB_STARTED | A production job has started |
| JOB_PAUSED | A production job has been paused |
| JOB_RESUMED | A paused job has been resumed |
| JOB_COMPLETED | A production job has been completed |
| PHASER_ORDER_CREATED | A new phaser order was created |
| PHASER_ORDER_ARCHIVED | A phaser order was archived |
| PHASER_ORDER_UPDATED | A phaser order was updated |
| PHASER_ORDER_ROW_CREATED | A new row was added to a phaser order |
| PHASER_ORDER_ROW_DELETED | A row was deleted from a phaser order |
| PHASER_ORDER_ROW_UPDATED | A phaser order row was updated |
| PHASER_JOB_CREATED | A new phaser job was created |
| PHASER_JOB_DELETED | A phaser job was deleted |
| PHASER_JOB_UPDATED | A phaser job was updated |
Payload Format #
All webhooks are sent as HTTP POST requests with a JSON body:
{
"event": "Human readable event description",
"data": {
// Event-specific data (see examples below)
},
"timestamp": 1696262400
}
HTTP Headers #
Every webhook request includes these headers:
| Header | Description |
| Content-Type | application/json |
| User-Agent | Skyplanner-Webhook/1.0 |
| X-Webhook-Source | Skyplanner |
| X-Webhook-Signature | HMAC-SHA256 signature (if secret is configured) |
| X-Webhook-Timestamp | Unix timestamp when webhook was sent (if secret is configured) |
Setting Up Your Receiver #
Requirements #
Your webhook receiver endpoint must:
- Accept HTTP POST requests
- Be accessible from the internet (or from Skyplanner’s server)
- Respond with a 2xx status code to acknowledge receipt
- Process requests within 30 seconds (default timeout)
PHP Example #
<?php
// webhook-receiver.php
// Get the raw POST body
$rawPayload = file_get_contents('php://input');
// Get headers
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
// Your shared secret (same as configured in Skyplanner)
$secret = 'your-webhook-secret-here';
// Verify signature (recommended)
if (!empty($secret) && !empty($signature)) {
// Check timestamp to prevent replay attacks (5 minute window)
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(401);
exit('Request too old');
}
// Verify HMAC signature
$expectedSignature = hash_hmac('sha256', $rawPayload, $secret);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
}
// Parse the webhook data
$webhook = json_decode($rawPayload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit('Invalid JSON');
}
// Extract webhook information
$event = $webhook['event'] ?? '';
$data = $webhook['data'] ?? [];
$webhookTimestamp = $webhook['timestamp'] ?? 0;
// Process the webhook based on event type
if (strpos($event, 'Job started') !== false) {
// Handle job started
$jobId = $data['production_planning_job_id'] ?? null;
$phaserJobId = $data['phaser_job_id'] ?? null;
// ... your logic here
} elseif (strpos($event, 'Phaser Order created') !== false) {
// Handle order created
$orderId = $data['id'] ?? null;
$orderNumber = $data['number'] ?? '';
// ... your logic here
}
// Log the webhook (optional)
error_log("Received webhook: {$event}");
// Respond with success
http_response_code(200);
echo json_encode(['status' => 'received']);
Node.js Example #
// webhook-receiver.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = 'your-webhook-secret-here';
// Parse raw body for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Verify signature (recommended)
if (SECRET && signature) {
// Check timestamp (5 minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(401).json({ error: 'Request too old' });
}
// Verify HMAC signature
const expectedSignature = crypto
.createHmac('sha256', SECRET)
.update(req.rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
}
// Process the webhook
const { event, data, timestamp: webhookTimestamp } = req.body;
console.log(`Received webhook: ${event}`);
console.log('Data:', JSON.stringify(data, null, 2));
// Handle different event types
if (event.includes('Job started')) {
const { production_planning_job_id, phaser_job_id } = data;
// ... your logic here
} else if (event.includes('Phaser Order created')) {
const { id, number } = data;
// ... your logic here
}
// Acknowledge receipt
res.status(200).json({ status: 'received' });
});
app.listen(3000, () => {
console.log('Webhook receiver listening on port 3000');
});
Python example #
# webhook_receiver.py
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
app = Flask(__name__)
SECRET = 'your-webhook-secret-here'
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
"""Verify the webhook signature."""
if not SECRET or not signature:
return True # Skip verification if no secret configured
# Check timestamp (5 minute window)
try:
webhook_time = int(timestamp)
if abs(time.time() - webhook_time) > 300:
return False
except (ValueError, TypeError):
return False
# Verify HMAC signature
expected = hmac.new(
SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks', methods=['POST'])
def receive_webhook():
# Get raw payload for signature verification
raw_payload = request.get_data()
# Get headers
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
# Verify signature
if not verify_signature(raw_payload, signature, timestamp):
return jsonify({'error': 'Invalid signature'}), 401
# Parse webhook data
webhook = request.get_json()
if not webhook:
return jsonify({'error': 'Invalid JSON'}), 400
event = webhook.get('event', '')
data = webhook.get('data', {})
webhook_timestamp = webhook.get('timestamp', 0)
print(f"Received webhook: {event}")
print(f"Data: {data}")
# Handle different event types
if 'Job started' in event:
job_id = data.get('production_planning_job_id')
phaser_job_id = data.get('phaser_job_id')
# ... your logic here
elif 'Phaser Order created' in event:
order_id = data.get('id')
order_number = data.get('number')
# ... your logic here
return jsonify({'status': 'received'}), 200
if __name__ == '__main__':
app.run(port=3000)
Example payloads #
Job Started #
{
"event": "Job started",
"data": {
"id": 45678,
"person_id": 12,
"production_planning_job_id": 1234,
"time": "2024-01-15T08:30:00+00:00",
"phaser_job_id": 5678,
"phaser_job_external_id": "EXT-001"
},
"timestamp": 1705308600
}
Job Paused #
{
"event": "Job paused",
"data": {
"id": 45679,
"person_id": 12,
"production_planning_job_id": 1234,
"time": "2024-01-15T09:15:00+00:00",
"phaser_job_id": 5678,
"phaser_job_external_id": "EXT-001",
"duration": 2700,
"amount": 50,
"faulty_amount": 2,
"begin_id": 45678
},
"timestamp": 1705311300
}
Job Resumed #
{
"event": "Job resumed",
"data": {
"id": 45680,
"person_id": 12,
"production_planning_job_id": 1234,
"time": "2024-01-15T09:45:00+00:00",
"phaser_job_id": 5678,
"phaser_job_external_id": "EXT-001"
},
"timestamp": 1705313100
}
Job Completed #
{
"event": "Job completed",
"data": {
"id": 45681,
"person_id": 12,
"production_planning_job_id": 1234,
"time": "2024-01-15T10:45:00+00:00",
"phaser_job_id": 5678,
"phaser_job_external_id": "EXT-001",
"duration": 8100,
"amount": 100,
"faulty_amount": 3,
"begin_id": 45678
},
"timestamp": 1705316700
}
Phaser Order Created #
{
"event": "Phaser Order created",
"data": {
"order_id": 789,
"order_number": "ORD-2024-0042",
"production_planning_customer_id": 15,
"description": "Customer order for widgets",
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-25T00:00:00+00:00"
},
"timestamp": 1705327200
}
Phaser Order Updated #
{
"event": "Phaser Order updated",
"data": {
"order_id": 789,
"order_number": "ORD-2024-0042",
"production_planning_customer_id": 15,
"description": "Updated customer order for widgets",
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-22T00:00:00+00:00"
},
"timestamp": 1705332600
}
Phaser Order Archived #
{
"event": "Phaser Order archived",
"data": {
"order_id": 789,
"order_number": "ORD-2024-0042",
"production_planning_customer_id": 15,
"description": "Customer order for widgets",
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-25T00:00:00+00:00"
},
"timestamp": 1706256000
}
Phaser Order Row Created #
{
"event": "Phaser Order row created",
"data": {
"phaser_order_row_id": 1234,
"phaser_order_id": 789,
"production_planning_product_id": 456,
"worknumber": "WRK-001",
"description": "Standard widget assembly",
"amount": 100,
"ordered_amount": 100,
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-25T00:00:00+00:00"
},
"timestamp": 1705327500
}
Phaser Order Row Updated #
{
"event": "Phaser Order row updated",
"data": {
"phaser_order_row_id": 1234,
"phaser_order_id": 789,
"production_planning_product_id": 456,
"worknumber": "WRK-001",
"description": "Standard widget assembly",
"amount": 150,
"ordered_amount": 150,
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-25T00:00:00+00:00"
},
"timestamp": 1705333200
}
Phaser Order Row Deleted #
{
"event": "Phaser Order row deleted",
"data": {
"phaser_order_row_id": 1234,
"phaser_order_id": 789,
"production_planning_product_id": 456,
"worknumber": "WRK-001",
"description": "Standard widget assembly",
"amount": 100,
"ordered_amount": 100,
"start_eligibility_date": "2024-01-16T00:00:00+00:00",
"delivery_date": "2024-01-25T00:00:00+00:00"
},
"timestamp": 1705334400
}
Phaser Job Created #
{
"event": "Phaser job created",
"data": {
"phaser_job_id": 5678,
"phaser_order_row_id": 1234,
"duration": 7200,
"settingtime": 300,
"settletime": 0,
"min_degree": null,
"workstations": [1, 2, 3],
"start_eligibility_date": "2024-01-16T00:00:00+00:00"
},
"timestamp": 1705328100
}
Phaser Job Updated #
{
"event": "Phaser job updated",
"data": {
"phaser_job_id": 5678,
"phaser_order_row_id": 1234,
"duration": 9000,
"settingtime": 300,
"settletime": 0,
"min_degree": null,
"workstations": [1, 2, 4],
"start_eligibility_date": "2024-01-17T00:00:00+00:00"
},
"timestamp": 1705335000
}
Phaser Job Deleted #
{
"event": "Phaser job deleted",
"data": {
"phaser_job_id": 5678,
"phaser_order_row_id": 1234,
"duration": 7200,
"settingtime": 300,
"settletime": 0,
"min_degree": null,
"workstations": [1, 2, 3],
"start_eligibility_date": "2024-01-16T00:00:00+00:00"
},
"timestamp": 1705338000
}
Data Field Reference #
Job Events (JOB_STARTED, JOB_PAUSED, JOB_RESUMED, JOB_COMPLETED) #
| Field | Type | Description |
| id | integer | Timelog entry ID |
| person_id | integer | ID of the person performing the job |
| production_planning_job_id | integer | Production planning job ID |
| time | string | Timestamp of the event |
| phaser_job_id | integer | Phaser job ID (if available) |
| phaser_job_external_id | string | External ID of the phaser job (if available) |
| duration | integer | Duration in seconds (only for paused/completed) |
| amount | float | Quantity produced (only for paused/completed) |
| faulty_amount | float | Faulty quantity (only for paused/completed) |
| begin_id | integer | ID of the start timelog entry (only for paused/completed) |
Phaser Order Events #
| Field | Type | Description |
| order_id | integer | Phaser order ID |
| order_number | string | Order number |
| production_planning_customer_id | integer | Customer ID |
| description | string | Order description |
| start_eligibility_date | string | Earliest start date |
| delivery_date | string | Delivery deadline |
Phaser Order Row Events #
| Field | Type | Description |
| phaser_order_row_id | integer | Order row ID |
| phaser_order_id | integer | Parent order ID |
| production_planning_product_id | integer | Product ID |
| worknumber | string | Work number identifier |
| description | string | Row description |
| amount | float | Produced quantity |
| ordered_amount | float | Ordered quantity |
| start_eligibility_date | string | Earliest start date |
| delivery_date | string | Delivery deadline |
Phaser Job Events #
| Field | Type | Description |
| phaser_job_id | integer | Phaser job ID |
| phaser_order_row_id | integer | Parent order row ID |
| duration | integer | Estimated duration in seconds |
| settingtime | integer | Setup time in seconds |
| settletime | integer | Settle time in seconds |
| min_degree | float/null | Minimum degree of manufacture |
| workstations | array | List of workstation IDs this job can run on |
| start_eligibility_date | string | Earliest start date |
Signature Verification #
Skyplanner signs webhooks using HMAC-SHA256. To verify:
- Get the raw request body (before JSON parsing)
- Get the `X-Webhook-Signature` header
- Calculate `HMAC-SHA256(raw_body, your_secret)`
- Compare your calculated signature with the received one using a timing-safe comparison
Important: Always use timing-safe comparison functions (`hash_equals` in PHP, `crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python) to prevent timing attacks.
Replay Attack Prevention #
The ‘X-Webhook-Timestamp’ header contains the Unix timestamp when the webhook was sent. Reject requests older than 5 minutes to prevent replay attacks:
<?php
if (abs(time() - (int)$timestamp) > 300) {
// Reject: request too old
}
Retry Behavior #
If your endpoint doesn’t respond with a 2xx status code, Skyplanner will retry the webhook:
- Maximum attempts: 3 (by default)
- Retry interval: 5 minutes between attempts
- Timeout: 30 seconds per request
Ensure your endpoint is idempotent, as the same webhook may be delivered multiple times.