# Webkio webhooks

Subscribe a URL to an event (in the developer console, with `POST /v1/webhooks`, or through the n8n nodes) and Webkio sends it every such event, the moment it happens.

## What a delivery looks like

An HTTPS `POST` with a JSON body: the record in `data`, the site it happened on in `project`. Deliveries go out within about a minute of the event.

| Header | What it holds |
|---|---|
| `X-Webkio-Event` | The event, e.g. `order.paid`. |
| `X-Webkio-Delivery` | This delivery's id. A retry keeps it, so you can tell a retry from a new delivery. |
| `X-Webkio-Timestamp` | Unix seconds when it was sent. Part of the signature. |
| `X-Webkio-Signature` | `v1=` and the hex HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with the webhook's signing secret. |
| `X-Webkio-Test` | `1` on a test sent from the console. Absent otherwise. |

- **Answer with any 2xx within 10 seconds.** Do slow work after you answer.
- **Anything else is retried**, up to 6 tries over about half an hour. A delivery you accepted is never sent again.
- **The body's `id` is the same** every time the same record is sent (a retry, or a Resend from the console): use it to ignore duplicates.
- **Answer `410 Gone`** and the webhook removes itself.
- **After 6 failed attempts in a row** the account owner is emailed a warning (at most once a day for each webhook). **After 30** the webhook is switched off and the owner is emailed again; turn it on again from the console once your endpoint works. A paused webhook gets nothing until it is resumed, and misses what happens meanwhile.

## Verify the signature

Recompute the signature from the raw body and the timestamp header, compare it in constant time, and refuse a timestamp older than five minutes, so a captured delivery cannot be replayed. The examples accept a list of secrets: normally one, and two while you change it.

JavaScript:

```javascript
// Node.js (Express). Read the RAW body: the signature covers the exact bytes we sent.
const crypto = require('crypto');
const express = require('express');

// secrets: the webhook's signing secret, or two while you change it (current and new).
function verifyWebkio(rawBody, timestamp, signature, secrets) {
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute window
  if (typeof signature !== 'string') return false;
  return [].concat(secrets).some((secret) => {
    const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
    return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  });
}

// The secret; while you change it, both, separated by a comma.
const secrets = process.env.WEBKIO_WEBHOOK_SECRET.split(',');

const app = express();
app.post('/webkio', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebkio(req.body.toString('utf8'), req.get('X-Webkio-Timestamp'), req.get('X-Webkio-Signature'), secrets);
  if (!ok) return res.sendStatus(401);
  const delivery = JSON.parse(req.body);
  // ... handle delivery.event / delivery.data, then answer quickly:
  res.sendStatus(200);
});
```

PHP:

```php
<?php
// Read the RAW body: the signature covers the exact bytes we sent.
// $secrets: the webhook's signing secret, or two while you change it (current and new).
function verify_webkio(string $rawBody, string $timestamp, string $signature, array $secrets): bool
{
    if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) { // 5-minute window
        return false;
    }
    foreach ($secrets as $secret) {
        if (hash_equals('v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), $signature)) {
            return true;
        }
    }
    return false;
}

$raw = file_get_contents('php://input');
$ok = verify_webkio(
    $raw,
    $_SERVER['HTTP_X_WEBKIO_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_WEBKIO_SIGNATURE'] ?? '',
    // The secret; while you change it, both, separated by a comma.
    explode(',', (string) getenv('WEBKIO_WEBHOOK_SECRET'))
);
if (!$ok) {
    http_response_code(401);
    exit;
}
$delivery = json_decode($raw, true);
// ... handle $delivery['event'] / $delivery['data'], then answer quickly:
http_response_code(200);
```

Python:

```python
# Flask. Read the RAW body: the signature covers the exact bytes we sent.
import hashlib, hmac, os, time
from flask import Flask, request, abort

# secrets: the webhook's signing secret, or two while you change it (current and new).
def verify_webkio(raw_body: bytes, timestamp: str, signature: str, secrets: list[str]) -> bool:
    if not timestamp or abs(time.time() - int(timestamp)) > 300:  # 5-minute window
        return False
    signed = timestamp.encode() + b'.' + raw_body
    return any(hmac.compare_digest('v1=' + hmac.new(s.encode(), signed, hashlib.sha256).hexdigest(), signature or '')
               for s in secrets)

app = Flask(__name__)

@app.post('/webkio')
def webkio():
    ok = verify_webkio(request.get_data(), request.headers.get('X-Webkio-Timestamp', ''),
                       request.headers.get('X-Webkio-Signature', ''),
                       os.environ['WEBKIO_WEBHOOK_SECRET'].split(','))  # both, comma-separated, while you change it
    if not ok:
        abort(401)
    delivery = request.get_json()
    # ... handle delivery['event'] / delivery['data'], then answer quickly:
    return '', 200
```

**Changing the secret without missing a delivery.** Rotate signing secret in the console creates a new one while deliveries stay signed with the current one. Set your endpoint to accept both, check it with Send test, which signs with the new secret during the change, then choose Finish. A webhook added by Zapier, Make or n8n keeps the secret its integration was given.

## Events and example payloads

### New order (`order.created`)

An order is placed in your shop. Card payments may still be pending.

```json
{
    "id": "order.created_Ab12Cd",
    "event": "order.created",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Ab12Cd",
        "number": "ORD-260914-0001",
        "status": "pending",
        "type": "shipping",
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com",
            "phone": "+44 20 7946 0958"
        },
        "currency": "GBP",
        "subtotal": 42.5,
        "shipping": 4.95,
        "tip": null,
        "discount": null,
        "tax": 0,
        "total": 47.45,
        "payment_method": "card",
        "shipping_method": "Standard",
        "shipping_address": {
            "line1": "1 High Street",
            "city": "London",
            "postcode": "EC1A 1AA",
            "country": "GB"
        },
        "coupon_code": null,
        "note": null,
        "scheduled_for": null,
        "items": [
            {
                "name": "Linen apron",
                "sku": "APR-01",
                "quantity": 2,
                "price": 21.25,
                "total": 42.5
            }
        ],
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### Order paid (`order.paid`)

Payment for an order is confirmed.

```json
{
    "id": "order.paid_Ab12Cd",
    "event": "order.paid",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "status": "paid",
        "id": "Ab12Cd",
        "number": "ORD-260914-0001",
        "type": "shipping",
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com",
            "phone": "+44 20 7946 0958"
        },
        "currency": "GBP",
        "subtotal": 42.5,
        "shipping": 4.95,
        "tip": null,
        "discount": null,
        "tax": 0,
        "total": 47.45,
        "payment_method": "card",
        "shipping_method": "Standard",
        "shipping_address": {
            "line1": "1 High Street",
            "city": "London",
            "postcode": "EC1A 1AA",
            "country": "GB"
        },
        "coupon_code": null,
        "note": null,
        "scheduled_for": null,
        "items": [
            {
                "name": "Linen apron",
                "sku": "APR-01",
                "quantity": 2,
                "price": 21.25,
                "total": 42.5
            }
        ],
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### New booking (`booking.created`)

A customer books an appointment.

```json
{
    "id": "booking.created_Bk34Ef",
    "event": "booking.created",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Bk34Ef",
        "service": "Haircut and finish",
        "date": "2026-09-20",
        "time": "14:30",
        "status": "confirmed",
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com",
            "phone": "+44 20 7946 0958"
        },
        "amount_due": 45,
        "currency": "GBP",
        "payment_status": "unpaid",
        "address": null,
        "notes": "First visit",
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### New rental reservation (`rental.reserved`)

A customer reserves a rental item.

```json
{
    "id": "rental.reserved_Rv56Gh",
    "event": "rental.reserved",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Rv56Gh",
        "reference": "RSV-1042",
        "item": "Volkswagen Golf",
        "status": "pending",
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com",
            "phone": "+44 20 7946 0958"
        },
        "pickup": {
            "date": "2026-09-21",
            "time": "10:00",
            "location": "Airport desk"
        },
        "return": {
            "date": "2026-09-24",
            "time": "10:00",
            "location": "Airport desk"
        },
        "days": 3,
        "total": 180,
        "deposit": 200,
        "currency": "GBP",
        "payment_status": "unpaid",
        "notes": null,
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### New subscriber (`subscriber.created`)

Someone joins your email list.

```json
{
    "id": "subscriber.created_Sb78Ij",
    "event": "subscriber.created",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Sb78Ij",
        "email": "sofia@example.com",
        "name": "Sofia Almeida",
        "status": "active",
        "tags": [
            "newsletter"
        ],
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### New property enquiry (`property_enquiry.created`)

A visitor enquires about a property listing.

```json
{
    "id": "property_enquiry.created_Pe90Kl",
    "event": "property_enquiry.created",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Pe90Kl",
        "type": "viewing",
        "status": "new",
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com",
            "phone": "+44 20 7946 0958"
        },
        "message": "Could I view it on Saturday morning?",
        "listing": {
            "id": "Ls11Mn",
            "title": "3 bed terrace, Southville"
        },
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```

### New product review (`review.submitted`)

A customer reviews a product.

```json
{
    "id": "review.submitted_Rw22Op",
    "event": "review.submitted",
    "project": {
        "id": "xYz12AbC",
        "name": "Your Shop",
        "timezone": "Europe/London"
    },
    "data": {
        "id": "Rw22Op",
        "rating": 5,
        "title": "Lovely quality",
        "content": "Washed well and still looks new.",
        "status": "pending",
        "verified_purchase": true,
        "customer": {
            "name": "Sofia Almeida",
            "email": "sofia@example.com"
        },
        "product": {
            "id": "Pr33Qr",
            "name": "Linen apron"
        },
        "created_at": "2026-09-14T09:30:00Z"
    }
}
```
