---
title: Webhooks
description: "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…"
url: "https://skyplanner.ai/docs/webhooks/"
updated: "2026-05-21"
category: Integrations
---

# Webhooks

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 |
| SCHEDULING_CONFIRMED | Scheduled plan was confirmed |

#### 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:

1. Accept HTTP POST requests
2. Be accessible from the internet (or from Skyplanner's server)
3. Respond with a 2xx status code to acknowledge receipt
4. 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
}
```

##### **Scheduling Confirmed**

```
{
  "event": "Scheduled jobs confirmed",
  "data": {
    "planned_by": 12
  },
  "timestamp": 1705340000
}
```

#### 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 |

##### Scheduling Confirmed Event

| **Field** | **Type** | **Description** |
| --- | --- | --- |
| planned_by | integer | ID of the person who confirmed the schedule |

#### Signature Verification

Skyplanner signs webhooks using HMAC-SHA256. To verify:

1. Get the raw request body (before JSON parsing)
2. Get the `X-Webhook-Signature` header
3. Calculate `HMAC-SHA256(raw_body, your_secret)`
4. 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.
