> ## Documentation Index
> Fetch the complete documentation index at: https://jobticket-docs.ticket-plus.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Real-time event notifications, payload schemas, and signature verification

## Overview

JobTicket+ delivers webhook events as HTTP `POST` requests to your registered endpoint. Each delivery includes:

* A **JSON body** containing the event metadata and resource payload
* An **`X-Signing-Signature` header** you must verify to confirm the request genuinely came from JobTicket+

***

## Event Types

### `employee-subscription-changed`

Fired whenever an employee's subscription status or key dates change (e.g. activation, scheduled pause, cancellation).

#### Payload

```json theme={null}
{
  "event": {
    "id": "5abc1524-41f1-4454-9245-0ceb7b0d6382",
    "type": "employee-subscription-changed",
    "timestamp": 1778662082
  },
  "payload": {
    "id": 4443,
    "resource": "employee",
    "subscription": {
      "status": "scheduled-pause",
      "startAt": "2026-05-01",
      "pausedAt": "2026-06-01",
      "cancelledAt": null,
      "nextTicketAt": null,
      "reactivatedAt": null
    }
  }
}
```

#### Fields

| Field                                | Type           | Description                                           |
| ------------------------------------ | -------------- | ----------------------------------------------------- |
| `event.id`                           | string (UUID)  | Unique ID of this event                               |
| `event.type`                         | string         | Always `employee-subscription-changed`                |
| `event.timestamp`                    | integer        | Unix timestamp (seconds) when the event was generated |
| `payload.id`                         | integer        | Employee ID                                           |
| `payload.resource`                   | string         | Always `employee`                                     |
| `payload.subscription.status`        | string         | New subscription status                               |
| `payload.subscription.startAt`       | string \| null | ISO 8601 date the subscription starts                 |
| `payload.subscription.pausedAt`      | string \| null | ISO 8601 date the subscription will be / was paused   |
| `payload.subscription.cancelledAt`   | string \| null | ISO 8601 date the subscription was cancelled          |
| `payload.subscription.nextTicketAt`  | string \| null | ISO 8601 date the next ticket will be issued          |
| `payload.subscription.reactivatedAt` | string \| null | ISO 8601 date the subscription was reactivated        |

***

### `new-employee-document-generated`

Fired when a new document has been generated and is ready for an employee.

#### Payload

```json theme={null}
{
  "event": {
    "id": "5abc1524-41f1-4454-9245-0ceb7b0d6382",
    "type": "new-employee-document-generated",
    "timestamp": 1778662082
  },
  "payload": {
    "id": 25,
    "employeeId": 4443,
    "resource-type": "employee-document"
  }
}
```

#### Fields

| Field                   | Type          | Description                                                                |
| ----------------------- | ------------- | -------------------------------------------------------------------------- |
| `event.id`              | string (UUID) | Unique ID of this event                                                    |
| `event.type`            | string        | Always `new-employee-document-generated`                                   |
| `event.timestamp`       | integer       | Unix timestamp (seconds) when the event was generated                      |
| `payload.id`            | integer       | Document ID — use with [Get Employee Document](/v1/endpoint/documents-get) |
| `payload.employeeId`    | integer       | ID of the employee the document belongs to                                 |
| `payload.resource-type` | string        | Always `employee-document`                                                 |

***

## Verifying Signatures

JobTicket+ signs every webhook delivery so you can prove the request came from JobTicket+ and has not been tampered with. **You should reject any request that fails verification.**

### How the signature is built

When JobTicket+ sends a webhook it:

1. Takes the **raw JSON body** as a string (byte-for-byte, before any parsing).
2. Takes the **current Unix timestamp** in seconds (call it `t`).
3. Concatenates them with a dot: `<rawBody>.<t>`.
4. Computes **HMAC-SHA256** of that string using your **first signing secret** → `s1`.
5. Computes **HMAC-SHA256** of that same string using your **second signing secret** → `s2`.
6. Puts everything in the `X-Signing-Signature` request header:

```
X-Signing-Signature: t=1778662083,s1=26ed1320ffa37adb…,s2=26ed1320ffa37adb…
```

The signed message is always: **`<rawBody>.<t>`** — the literal request body string, a dot, and the timestamp integer.

***

### How you verify it

<Steps>
  <Step title="Capture the raw request body">
    Read the **raw bytes** of the incoming request body *before* JSON-parsing it. Any difference in whitespace or encoding will break the HMAC comparison. Most frameworks provide a raw-body hook — use it.
  </Step>

  <Step title="Parse the signature header">
    Split `X-Signing-Signature` on `,` and then on `=` to extract the three parts:

    ```
    t   → the timestamp JobTicket+ used when signing
    s1  → HMAC computed with secret 1
    s2  → HMAC computed with secret 2
    ```
  </Step>

  <Step title="Reject stale events (replay protection)">
    Compare `t` to your current time. If the event is older than **5 minutes**, reject it — this prevents an attacker from capturing a valid request and replaying it later.

    ```
    if |now() - t| > 300 seconds  →  return 400 / discard
    ```
  </Step>

  <Step title="Re-compute the expected signatures">
    Using the same formula JobTicket+ used, compute the HMAC for each of your two signing secrets:

    ```
    message   = "<rawBody>.<t>"
    expected1 = HMAC-SHA256(key=secret1, message=message)
    expected2 = HMAC-SHA256(key=secret2, message=message)
    ```
  </Step>

  <Step title="Accept if either secret matches">
    Compare the values you computed against `s1` and `s2` from the header **using a constant-time comparison** (to prevent timing attacks).

    ```
    valid = constantTimeEqual(s1, expected1)
         OR constantTimeEqual(s2, expected2)
    ```

    If **at least one** matches, the request is authentic. If neither matches, reject it with `400`.

    <Note>
      Accepting either secret is intentional — it lets you rotate one secret at a time without dropping valid deliveries. See [Zero-downtime rotation](#zero-downtime-secret-rotation) below.
    </Note>
  </Step>
</Steps>

***

### Code examples

<CodeGroup>
  ```typescript TypeScript theme={null}
  import crypto from "crypto";

  function verifyWebhookSignature(
    rawBody: string, // raw request body string, NOT parsed JSON
    signatureHeader: string, // full value of X-Signing-Signature header
    secret1: string,
    secret2: string,
    toleranceSeconds = 300,
  ): boolean {
    // 1. Parse the header
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
    );
    const { t, s1, s2 } = parts;
    if (!t || !s1 || !s2) return false;

    // 2. Replay protection — reject events older than toleranceSeconds
    const nowSeconds = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSeconds - parseInt(t, 10)) > toleranceSeconds) return false;

    // 3. Re-compute the signatures using the same message JobTicket+ signed
    const message = `${rawBody}.${t}`;
    const expected1 = crypto
      .createHmac("sha256", secret1)
      .update(message)
      .digest("hex");
    const expected2 = crypto
      .createHmac("sha256", secret2)
      .update(message)
      .digest("hex");

    // 4. Constant-time comparison — accept if either secret matches
    const match1 = crypto.timingSafeEqual(
      Buffer.from(s1),
      Buffer.from(expected1),
    );
    const match2 = crypto.timingSafeEqual(
      Buffer.from(s2),
      Buffer.from(expected2),
    );

    return match1 || match2;
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_webhook_signature(
      raw_body: str,           # raw request body string, NOT parsed JSON
      signature_header: str,   # full value of X-Signing-Signature header
      secret1: str,
      secret2: str,
      tolerance_seconds: int = 300,
  ) -> bool:
      # 1. Parse the header
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      t, s1, s2 = parts.get("t"), parts.get("s1"), parts.get("s2")
      if not all([t, s1, s2]):
          return False

      # 2. Replay protection — reject events older than tolerance_seconds
      if abs(time.time() - int(t)) > tolerance_seconds:
          return False

      # 3. Re-compute the signatures using the same message JobTicket+ signed
      message = f"{raw_body}.{t}".encode()
      expected1 = hmac.new(secret1.encode(), message, hashlib.sha256).hexdigest()
      expected2 = hmac.new(secret2.encode(), message, hashlib.sha256).hexdigest()

      # 4. Constant-time comparison — accept if either secret matches
      return hmac.compare_digest(s1, expected1) or hmac.compare_digest(s2, expected2)
  ```

  ```kotlin Kotlin theme={null}
  import javax.crypto.Mac
  import javax.crypto.spec.SecretKeySpec
  import java.security.MessageDigest
  import kotlin.math.abs

  fun verifyWebhookSignature(
      rawBody: String,           // raw request body string, NOT parsed JSON
      signatureHeader: String,   // full value of X-Signing-Signature header
      secret1: String,
      secret2: String,
      toleranceSeconds: Long = 300
  ): Boolean {
      // 1. Parse the header
      val parts = signatureHeader.split(",").associate {
          val (k, v) = it.split("=", limit = 2); k to v
      }
      val t  = parts["t"]  ?: return false
      val s1 = parts["s1"] ?: return false
      val s2 = parts["s2"] ?: return false

      // 2. Replay protection — reject events older than toleranceSeconds
      val nowSeconds = System.currentTimeMillis() / 1000
      if (abs(nowSeconds - t.toLong()) > toleranceSeconds) return false

      // 3. Re-compute the signatures using the same message JobTicket+ signed
      val message = "$rawBody.$t"
      fun hmac256(secret: String): String {
          val mac = Mac.getInstance("HmacSHA256")
          mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
          return mac.doFinal(message.toByteArray()).joinToString("") { "%02x".format(it) }
      }
      val expected1 = hmac256(secret1)
      val expected2 = hmac256(secret2)

      // 4. Constant-time comparison — accept if either secret matches
      return MessageDigest.isEqual(s1.toByteArray(), expected1.toByteArray()) ||
             MessageDigest.isEqual(s2.toByteArray(), expected2.toByteArray())
  }
  ```
</CodeGroup>

***

### Worked example

Given:

* **Raw body**: `{"event":{"id":"abc","type":"employee-subscription-changed","timestamp":1778662082},"payload":{"id":4443}}`
* **Timestamp** (`t`): `1778662083`
* **Secret 1**: `my-first-secret`

The signed message is:

```
{"event":{"id":"abc","type":"employee-subscription-changed","timestamp":1778662082},"payload":{"id":4443}}.1778662083
```

Running `HMAC-SHA256(key="my-first-secret", message=<above>)` produces `s1`. JobTicket+ puts that value in the header alongside `t` and `s2`.

When your server receives the request, it rebuilds the same message from its own raw body + the `t` from the header, runs the same HMAC, and compares. If the body was altered in transit — even by a single space — the HMAC will not match.

***

## Zero-downtime Secret Rotation

Each webhook has **two** signing secrets. JobTicket+ always includes both `s1` and `s2` in every delivery, and your handler should accept a request if **either** one matches. This design lets you rotate one secret without dropping any deliveries:

<Steps>
  <Step title="Rotate the first secret">
    In the portal, open the **⋯** menu for the webhook and click **Rotate First
    Webhook Signing Secret**. Your handler currently accepts either the old or
    new first secret (since `s2` is unchanged and still matches).
  </Step>

  <Step title="Deploy the new secret to your server">
    Update your environment variables / secret manager with the new first secret
    value. Your handler now matches on the new `s1`.
  </Step>

  <Step title="Rotate the second secret if needed">
    Repeat the process for the second secret. You now have two fresh secrets and
    the old values are no longer valid.
  </Step>
</Steps>

***

## Responding to Webhooks

Return any `2xx` HTTP status within **10 seconds** to acknowledge receipt.

<Warning>
  Do **not** perform slow or blocking work inside the webhook handler. Return
  `200 OK` immediately and push the payload onto a queue for async processing.
  If your handler times out, JobTicket+ logs it as a failed delivery.
</Warning>

***

## Security Checklist

<Check>Read the raw request body *before* JSON parsing</Check>
<Check>Parse `t`, `s1`, and `s2` from the `X-Signing-Signature` header</Check>
<Check>Reject the request if `|now - t| > 300 seconds` (replay protection)</Check>
<Check>Re-compute `HMAC-SHA256("<rawBody>.<t>")` for each of your two secrets</Check>
<Check>Use a constant-time comparison — never a plain string equality check</Check>
<Check>Accept the request if **either** computed HMAC matches `s1` or `s2`</Check>
<Check>Return `2xx` immediately; process the payload asynchronously</Check>
