Skip to main content

Documentation Index

Fetch the complete documentation index at: https://allridegmbh.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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

{
  "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

FieldTypeDescription
event.idstring (UUID)Unique ID of this event
event.typestringAlways employee-subscription-changed
event.timestampintegerUnix timestamp (seconds) when the event was generated
payload.idintegerEmployee ID
payload.resourcestringAlways employee
payload.subscription.statusstringNew subscription status
payload.subscription.startAtstring | nullISO 8601 date the subscription starts
payload.subscription.pausedAtstring | nullISO 8601 date the subscription will be / was paused
payload.subscription.cancelledAtstring | nullISO 8601 date the subscription was cancelled
payload.subscription.nextTicketAtstring | nullISO 8601 date the next ticket will be issued
payload.subscription.reactivatedAtstring | nullISO 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

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

Fields

FieldTypeDescription
event.idstring (UUID)Unique ID of this event
event.typestringAlways new-employee-document-generated
event.timestampintegerUnix timestamp (seconds) when the event was generated
payload.idintegerDocument ID — use with Get Employee Document
payload.employeeIdintegerID of the employee the document belongs to
payload.resource-typestringAlways 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 secrets1.
  5. Computes HMAC-SHA256 of that same string using your second signing secrets2.
  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

1

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.
2

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
3

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
4

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)
5

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.
Accepting either secret is intentional — it lets you rotate one secret at a time without dropping valid deliveries. See Zero-downtime rotation below.

Code examples

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;
}

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

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).
2

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.
3

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.

Responding to Webhooks

Return any 2xx HTTP status within 10 seconds to acknowledge receipt.
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.

Security Checklist

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