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

# Webhooks

> Receive signed events for jobs, computers and printers.

Webhooks push events to your HTTPS endpoint as they happen, so you do not need to poll. Every request is signed with your endpoint secret following the [Standard Webhooks](https://www.standardwebhooks.com) specification, retried for about 27 hours if your endpoint fails, and visible in a delivery log you can replay from the dashboard.

## Create an endpoint

In the dashboard, open Webhooks, choose Add endpoint, enter your URL and pick the events you want. Or call the API with a key that has the `admin` scope:

```bash theme={null}
curl https://api.printeasy.adfinite.com/v1/webhooks \
  -H "Authorization: Bearer $PRINTEASY_API_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/printeasy/webhooks","description":"ERP print status","events":["printjob.done","printjob.error"]}'
```

The response includes the signing `secret`, which starts with `whsec_`. It is shown once and never again, so store it in your secret manager now. If you lose it, delete the endpoint and create a new one.

Each endpoint belongs to one mode, the mode of the key that created it (or of the dashboard test mode toggle). It receives only events of that mode.

URLs must be `https:` for live endpoints, must not contain a username or password, and must resolve to a public address. The check runs when you create or change an endpoint and again before every send, so an address that later points at a private network is refused. A URL that fails answers `422 invalid_url`.

## Event types

| Event | Sent when |
| - | - |
| `printjob.queued` | A job is stored and waiting |
| `printjob.sent` | A job is pushed to the agent |
| `printjob.received` | The agent acknowledged the job |
| `printjob.printing` | The job reached the operating system spooler |
| `printjob.done` | The spooler finished the job |
| `printjob.error` | The job failed |
| `printjob.cancelled` | The job was cancelled |
| `printjob.expired` | The job was not received in time |
| `computer.online` | A computer's agent connected |
| `computer.offline` | A computer's agent disconnected |
| `printer.added` | A new printer appeared on a computer |
| `printer.removed` | A printer was removed from a computer |
| `printer.online` | A printer became available |
| `printer.offline` | A printer became unavailable |
| `printer.updated` | A printer's settings changed |

Subscribe an endpoint only to the events you handle. See [Printing PDFs](/guides/printing-pdfs) for what each job state means.

## Payload

Every request body is a JSON envelope:

```json theme={null}
{
  "id": "0192f1a0-7c00-7000-8000-000000000001",
  "type": "printjob.done",
  "timestamp": "2026-10-14T10:00:00.000Z",
  "mode": "test",
  "data": {
    "id": "0192f1a0-7c00-7000-8000-0000000000aa",
    "printerId": "0192f1a0-7c00-7000-8000-0000000000bb",
    "title": "Invoice 1042",
    "state": "done",
    "stateMessage": null
  }
}
```

`id` identifies the event and is the same on every retry and replay. `timestamp` is when the event happened and `mode` is `live` or `test`. For `printjob.*` events, `data` is the print job object as `GET /v1/printjobs/{id}` returns it (the example is shortened). For `computer.*` events, `data` holds the computer `id` with its `name` and `state`. For `printer.*` events, `data` holds the printer `id` and the `computerId` it belongs to, with the details of the change.

## Headers

| Header | Value |
| - | - |
| `webhook-id` | The event id. Stable across retries and replays |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 signature |
| `content-type` | `application/json` |
| `user-agent` | `PrintEasy-Webhooks/1.0` |

## Verify signatures

Always verify the signature before you trust a request. The SDKs check the signature and reject timestamps more than 300 seconds from now, which stops replayed requests.

<CodeGroup>
  ```ts Node theme={null}
  import express from "express";
  import { PrintEasy } from "@printeasy/sdk";

  const pe = new PrintEasy({ apiKey: process.env.PRINTEASY_API_KEY });
  const app = express();

  app.post("/printeasy/webhooks", express.text({ type: "application/json" }), (req, res) => {
    try {
      pe.webhooks.verify(req.body, req.headers, process.env.PRINTEASY_WEBHOOK_SECRET!);
    } catch {
      return res.status(400).end();
    }
    const event = JSON.parse(req.body);
    if (event.type === "printjob.done") console.log("printed", event.data.id);
    res.status(204).end();
  });
  ```

  ```python Python theme={null}
  import json
  import os

  from flask import Flask, request
  from printeasy import webhooks, WebhookVerificationError

  app = Flask(__name__)

  @app.post("/printeasy/webhooks")
  def receive():
      body = request.get_data(as_text=True)
      try:
          webhooks.verify(body, request.headers, os.environ["PRINTEASY_WEBHOOK_SECRET"])
      except WebhookVerificationError:
          return "", 400
      event = json.loads(body)
      if event["type"] == "printjob.done":
          print("printed", event["data"]["id"])
      return "", 204
  ```
</CodeGroup>

<Warning>
  Verify the raw body exactly as received. Parsing and re-serialising the JSON changes the bytes and breaks the signature. In Express, read the body as text (as above) rather than through `express.json()`.
</Warning>

Without the SDK, compute `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{body}"))`, where `key` is the base64 decoded part of the secret after `whsec_`. Compare the result with each `v1,` entry of `webhook-signature` (the header can hold several, separated by spaces) using a constant time comparison, and reject timestamps more than 300 seconds from now.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyManually(body: string, headers: Record<string, string>, secret: string, now = Math.floor(Date.now() / 1000)) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!id || !timestamp || Math.abs(now - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
  return (headers["webhook-signature"] ?? "").split(" ").some((part) => {
    const [version, signature] = part.split(",");
    const a = Buffer.from(signature ?? "");
    const b = Buffer.from(expected);
    return version === "v1" && a.length === b.length && timingSafeEqual(a, b);
  });
}
```

## Retries

Attempt 1 is sent immediately. A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Anything else (another status, a timeout, a connection error) is a failure, and PrintEasy waits and tries again:

| After failed attempt | Wait |
| - | - |
| 1 | 5 seconds |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 8 hours |
| 8 | 16 hours |

Each wait varies by plus or minus 20 percent (jitter). If attempt 9 also fails, the delivery is marked `failed`. A delivery is `pending` before its first send, `retrying` between attempts, `succeeded` after a 2xx and `failed` when attempts run out.

Respond quickly and do the real work afterwards, for example by putting the event on a queue. Redirects are not followed.

## Auto-disable after 5 days of failures

PrintEasy records when an endpoint started failing and clears that mark on any success. An endpoint whose deliveries keep failing for 5 days is disabled with the reason `Disabled automatically after 5 days of failed deliveries`, and every organization owner gets an email. Fix your receiver, then re-enable the endpoint in the dashboard (or `PATCH /v1/webhooks/{id}` with `{"enabled": true}`). Events produced while the endpoint was disabled are not sent to it; use [the event feed](#event-feed-alternative) to catch up.

## Replays and duplicates

The delivery log in the dashboard (and `GET /v1/webhooks/{id}/deliveries`) shows every attempt with its status code and the start of the response. Replay a delivery from the dashboard or with `POST /v1/webhooks/deliveries/{id}/replay`. A replay sends the same event again with the same `webhook-id`. Replays need an enabled endpoint.

Delivery is at least once, so a request can arrive more than once, and events can arrive out of order. Store the `webhook-id` values you have handled and skip repeats. Use the job `state` in `data` to ignore an older state that arrives after a newer one. Delivery logs are kept for 30 days.

## Test mode endpoints

An endpoint created with a test key, or while the dashboard is in test mode, receives only test events, and may use an `http:` URL (for example a tunnel to your laptop). Use it with the virtual printers in [Test mode](/guides/test-mode) to exercise your receiver end to end.

## Event feed alternative

If you cannot expose an endpoint, poll `GET /v1/events`. It returns the events of your key's mode, a page at a time. Use `dir=asc` with `after` set to the `nextCursor` of your previous call to read new events in order. Make the first call without `after` (an empty `after=` is rejected with `400`):

```bash theme={null}
curl "https://api.printeasy.adfinite.com/v1/events?dir=asc&limit=100" \
  -H "Authorization: Bearer $PRINTEASY_API_KEY"
```

Then pass the `nextCursor` you got back as `after` on each later call:

```bash theme={null}
curl "https://api.printeasy.adfinite.com/v1/events?dir=asc&limit=100&after=$CURSOR" \
  -H "Authorization: Bearer $PRINTEASY_API_KEY"
```

The response has `data`, `hasMore` and `nextCursor`. When a page is empty, `nextCursor` is the `after` you sent, so you can keep polling with the same value.

With `dir=asc`, an event shows up in the feed about 2 seconds after it happens. Keep `after` at the last event id you processed and poll again; do not skip ahead to a newer id, or you could miss an event that appears a moment later.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.