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

# Webhooks

> Be told in real time when a case changes: events, payload, signature, retries

Register a URL once and Penbox calls it every time a case of the workspace changes:
created, renamed, moved to another status, new data, a new task, an email received…
No polling. Each call says **which** case changed and **what** changed, with the new
values: the new title, the data values written, the new status. For anything else, read
the case again with [Get Case](https://docs.penbox.io/api-reference/cases/get-case.md): Penbox stays the source
of truth.

## Set up

1. [Create a webhook](https://docs.penbox.io/api-reference/webhooks/create-webhook.md) with the workspace and an HTTPS URL.
2. Store the `secret` of the response on your server: it signs every call.
3. Answer each call with a `2xx` within 10 seconds, then do your work.

Endpoints belong to one workspace. A partner managing several workspaces registers one
endpoint per workspace, with the token of each. Workspace admins can also add, inspect and
delete webhooks, and read their delivery log, in Penbox under **Settings → Webhooks**.

## Events

| Event | Sent when |
| - | - |
| `case.created` | a case is created |
| `case.updated` | something on the case changed; `changes` says what |
| `case.deleted` | a case is deleted |

Case templates never send events, only real cases.

## Payload

```json theme={null}
{
  "id": "evt_6b1f0c8e-6a4e-4c4b-9d0e-1f2a3b4c5d6e",
  "type": "case.updated",
  "version": 1,
  "occurred_at": "2026-10-24T13:00:00.000Z",
  "workspace": { "id": "1ec1c284-4c03-445e-a843-c03058466f2a" },
  "case": { "id": "4944431d-8fbe-4a28-a758-52f477b49e65" },
  "changes": [
    { "type": "status.changed", "status": { "from": "pending", "to": "in_progress" } },
    { "type": "data.updated", "data": { "plate": "1-ABC-123", "vin": null } },
    { "type": "task.created", "task": { "id": "0c565c29-a018-449c-9047-232c5ce42ca7" } }
  ]
}
```

Everything written in one operation comes in **one** event: renaming a case and moving
its status in the same call sends one `case.updated` with both changes. Within one event,
each field appears once with its value at the end of the operation.

## Changes

| `type` | Extra fields | When |
| - | - | - |
| `title.updated` | `title` | the title changed |
| `description.updated` | `description` | the description changed |
| `agent.instructions.updated` | `agent.instructions` | the instructions of the case agent changed |
| `reference.updated` | `reference` | the reference changed |
| `status.changed` | `status.from`, `status.to` | the status changed |
| `owner.changed` | `owner.id`, or `owner: null` | the owner changed or was removed |
| `archived` / `unarchived` | | the case was archived or restored |
| `contacts.updated` | | contacts were added, removed or replaced |
| `data.updated` | `data`: the values written, by key; `null` for a deleted key | data fields were written or deleted |
| `fields.updated` | `fields`: `{ name, type, is_deadline }` by key; `null` for a removed field | a field was declared, renamed, retyped, flagged as a deadline or removed |
| `task.created` / `task.updated` | `task.id` | a task was created or changed |
| `email.received` | `email.id` | an email arrived on the case |

> [!WARNING]
>
> New change types will be added. **Ignore the types you do not know** instead of
> failing: a new type never changes the meaning of the existing ones. `version` only
> moves for a breaking change to the envelope.

## Verify the signature

Every call carries a `Penbox-Signature` header:

```
Penbox-Signature: t=1792850416,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`v1` is the HMAC-SHA256, keyed with your secret, of the timestamp, a dot, and the raw
request body. Compute it on the **raw** body, before any JSON parsing, and reject calls
older than 5 minutes.

```javascript Node.js (Express) theme={null}
const crypto = require('crypto')
const express = require('express')

const app = express()

app.post('/penbox/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Penbox-Signature') ?? ''
  const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')))
  const expected = crypto
    .createHmac('sha256', process.env.PENBOX_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest('hex')

  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
  const valid =
    v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  if (!fresh || !valid) return res.sendStatus(400)

  const event = JSON.parse(req.body)
  res.sendStatus(200) // answer first, then re-read the case
  queue.push(event)
})
```

## Delivery and retries

| Your endpoint answers | Penbox |
| - | - |
| `2xx` | marks the event `delivered` |
| `4xx` | marks it `failed` and does not retry: your endpoint refused it |
| `5xx`, a timeout (10 s) or a network error | retries after 1, 2, 5, 15 and 30 minutes, then 1, 2, 4 and 6 hours, then every 6 hours; after 12 attempts the event is `dead` |

Delivery is **at least once**: the same event can arrive twice, for example after a
retry. Its `id` never changes, so keep the ids you processed and skip the ones you
already have.

Penbox sends an endpoint its events **one at a time, in the order the changes happened**:
you get `case.created` before the updates of that case. When your endpoint fails a call,
every later event waits for that one's retry, so the order holds through outages too.
Because delivery is at least once, an event you already processed can still come again:
skip it by its `id`, and before applying a value, check that the event's `occurred_at` is
newer than the last one you applied for that case.

See every event sent to an endpoint, its status and the last error in the
[delivery log](https://docs.penbox.io/api-reference/webhooks/list-webhook-deliveries.md). Entries are kept 30 days.


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