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

# Ticket webhooks

> Get a signed POST on your server whenever a ticket is opened, changed or resolved.

Add your HTTPS address in **Settings → Tickets → Webhook URL**. We post to it for every ticket, whether your team, your server or the API changed it.

| Event | When |
| - | - |
| `ticket.created` | A ticket is opened. |
| `ticket.updated` | Its status, priority, owner, due date, type or subject changes. |
| `ticket.resolved` | Its status moves to resolved. |

## The request

```http theme={null}
POST /your-webhook HTTP/1.1
Content-Type: application/json
User-Agent: Frictionless-Tickets/1
X-Frictionless-Event: ticket.resolved
X-Frictionless-Timestamp: 1762336800
X-Frictionless-Signature: sha256=5f2b…
```

```json theme={null}
{
  "event": "ticket.resolved",
  "ticket": {
    "id": "NE-1042",
    "ticketId": "c0a8f3e2-…",
    "subject": "Refund for order 381",
    "description": null,
    "type": "Refund",
    "priority": "high",
    "status": "resolved",
    "dueAt": null,
    "resolvedAt": "2026-11-05T10:00:00.000Z",
    "createdAt": "2026-11-04T09:12:00.000Z",
    "updatedAt": "2026-11-05T10:00:00.000Z",
    "notifyCustomer": true,
    "customer": {
      "name": "Ada Obi",
      "channel": "whatsapp",
      "phone": "+2348012345678",
      "email": null,
      "userId": "user_42"
    },
    "owner": { "name": "Kemi" },
    "chatUrl": "https://usefrictionless.com/dashboard?c=…",
    "url": "https://usefrictionless.com/tickets/NE-1042"
  },
  "changes": {
    "status": { "from": "IN_PROGRESS", "to": "RESOLVED" }
  },
  "sentAt": "2026-11-05T10:00:00.412Z"
}
```

* `ticket` has the same shape as the [Tickets API](https://docs.usefrictionless.com/api-reference/tickets/get-a-ticket.md) returns.
* `changes` is only on `ticket.updated` and `ticket.resolved`, with each field's old and new value.
* `customer.userId` is your own ID, when the customer is linked through the [Context API](https://docs.usefrictionless.com/context-api/overview.md).

## Check the signature

The signature works the same way as for [Actions](https://docs.usefrictionless.com/context-api/actions.md#check-the-signature). It is the HMAC-SHA256 of the timestamp, a full stop and the raw request body, keyed with your [webhook secret](https://docs.usefrictionless.com/authentication.md).

* Refuse the request if it doesn't match, or if the timestamp is more than five minutes old.
* While a secret rotation overlaps, the header carries two signatures separated by a comma. Accept the request if either matches.

```js Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

app.post("/your-webhook", express.text({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Frictionless-Timestamp");
  const expected = Buffer.from(
    "sha256=" + crypto.createHmac("sha256", process.env.FRICTIONLESS_WEBHOOK_SECRET)
      .update(timestamp + "." + req.body)
      .digest("hex")
  );
  const signatures = (req.get("X-Frictionless-Signature") || "").split(",").map((s) => Buffer.from(s.trim()));
  const matches = signatures.some((given) => given.length === expected.length && crypto.timingSafeEqual(given, expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!matches || !fresh) return res.sendStatus(401);

  const { event, ticket } = JSON.parse(req.body);
  // event is ticket.created, ticket.updated or ticket.resolved
  res.sendStatus(200);
});
```

## Delivery

* Answer with any 2xx within 10 seconds. We don't retry, so do slow work after you answer.
* The address must be HTTPS and reachable from the internet.
* **Settings → Tickets** shows when we last posted and the last error, if there was one. A failed post also shows on the ticket's timeline.


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