# Webhooks

Webhooks push events to an HTTPS endpoint you control, so your integration
learns about changes as they happen instead of polling for them. A webhook is
**workspace-scoped**: it fires for events in the team it was created in, and
never for any other team.

Webhooks are registered and managed through the v3 API:

```
https://openapi.niftypm.com/api/v3/webhooks
```

New to the API? Start with the [quickstart](/getting-started) — it gets you a
token and a first request.

## Managing webhooks

Webhook management uses the same bearer credential as the rest of the API — a
[Personal Access Token](/authentication) (`nft_user_<...>`) or an
[OAuth 2.1](/authorization) access token (`nft_oauth_<...>`).

```bash
curl -X POST https://openapi.niftypm.com/api/v3/webhooks \
  -H "Authorization: Bearer nft_user_<prefix>_<secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Task sync",
    "url": "https://example.com/webhooks/nifty",
    "events": ["task.created", "task.updated"]
  }'
```

```json
{
  "id": "8f1c4d2eXYab3Kd_",
  "name": "Task sync",
  "url": "https://example.com/webhooks/nifty",
  "events": ["task.created", "task.updated"],
  "apiVersion": "latest",
  "active": true,
  "failCount": 0,
  "secret": "whsec_<signing secret — shown once>"
}
```

:::warning
**The signing secret is returned exactly once.** `secret` appears in the create
response and in the response to `rotate-secret`, and nowhere else — reading a
webhook back never includes it, and replaying a create with the same
`Idempotency-Key` returns the webhook without it. Store it the moment you
receive it; if you lose it, [rotate](#rotating-the-secret) to get a new one.
:::

`POST` accepts an `Idempotency-Key` header. A replay within **10 minutes**
returns the original response instead of registering a second webhook.

### Permissions

Two checks apply to every call, and both must pass:

| | |
| --- | --- |
| **Token scope** | `webhooks:read` to list and read · `webhooks:write` to register **and** to update · `webhooks:delete` to delete and restore |
| **Role permission** | `manageWebhooks`, for create, update, delete and restore. Reading needs no role permission beyond membership of the team. |

`test` and `rotate-secret` are writes: both need `webhooks:write`.

### The four actions

CRUD — list, read, update, delete — is in the v3 API reference. Four actions
live under the webhook itself:

| Action | Does | Scope |
| --- | --- | --- |
| `POST /api/v3/webhooks/{id}/test` | Sends a synthetic delivery to the registered URL | `webhooks:write` |
| `POST /api/v3/webhooks/{id}/rotate-secret` | Mints a new signing secret and returns it once | `webhooks:write` |
| `GET /api/v3/webhooks/{id}/deliveries` | Lists recent delivery attempts, newest first | `webhooks:read` |
| `POST /api/v3/webhooks/{id}/restore` | Brings back a deleted webhook | `webhooks:delete` |

`DELETE` is a **soft delete**: the webhook stops receiving events immediately
and its `purgeAt` is stamped, but the registration is kept and `restore` brings
it back with the same id and the same secret.

## Events

Event names have one of three shapes:

| Family | Shape | Examples | Count |
| --- | --- | --- | --- |
| Lifecycle | `{resource}.{created\|updated\|removed\|restored\|force_deleted}` | `task.created`, `project.updated` | 155 |
| Membership | `{resource}.{relation}.{added\|removed}` | `task.assignees.added`, `list.goals.removed` | 38 |
| Operation | `{resource}.{verb}` | `document.append`, `project.transfer`, `webhook.rotateSecret` | 11 |

The full list is on [Webhook events](/webhook-events). `task.*` matches only the
lifecycle family; use `task.*.*` for membership events, list names explicitly,
or subscribe to `*`, which matches every event.

Operation event names keep the verb's camelCase spelling even though the URL
that triggers them is kebab-case: `POST /webhooks/{id}/rotate-secret` emits
`webhook.rotateSecret`.

### Subscribing with wildcards

A subscription is a list of patterns. Inside a pattern, `*` is a wildcard for
**one** dot segment, so it never spans a `.`. A pattern that is *only* `*` matches every event:

| Pattern | Matches |
| --- | --- |
| `task.created` | That event and nothing else |
| `task.*` | `task.created`, `task.updated`, … — but **not** `task.assignees.added` |
| `task.*.*` | The membership events — but **not** `task.created` |
| `*` | **Every** event, of any shape — lifecycle, membership and operation alike |

Subscribe to what you actually handle. A `*` subscription on a busy workspace
is a lot of traffic, and your endpoint has to acknowledge all of it.

### Registration limits

| Field | Limit |
| --- | --- |
| `events` | At most **100** entries |
| `events[]` entry | At most **128** characters, at most **4** wildcards, and no `,` |
| `url` | At most **2048** characters, `https://` only |
| `name` | At most **255** characters |
| `apiVersion` | `latest` (the default) or `2026-03-20` |

Every entry must be an exact catalog name or a wildcard that matches at least
one — unknown names are rejected at registration so a typo can't silently never
fire. A URL whose host is a private or link-local IP **literal** is rejected at
registration, and the host is re-checked against the same rules at delivery
time, so a DNS record that later resolves into private space stops being
delivered to.

## The payload

Every delivery is a `POST` with this envelope. The resource is under `payload`,
keyed by its name — it is **not** the top-level body.

```http
POST /webhooks/nifty HTTP/1.1
Content-Type: application/json
User-Agent: Nifty-Webhooks/2.0
X-Webhook-Id: 8f1c4d2eXYab3Kd_
X-Webhook-Event: task.created
X-Webhook-Delivery-Id: 8f1c4d2eXYab3Kd_:Qm7vR2xLp0aZ9tKd
X-Webhook-Signature: t=1788520718,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2
X-Api-Version: latest

{
  "eventId": "Qm7vR2xLp0aZ9tKd",
  "type": "task.created",
  "aggregateType": "task",
  "aggregateId": "Xi57few2aS8bQ1zL",
  "teamId": "Hf0SZf1HdkBAr2Xp",
  "projectId": null,
  "triggeredBy": {
    "userId": "RawEFOKjU5yw9hM",
    "teamMemberId": "Mb3kQ9wRt6vY0zAe",
    "source": "human"
  },
  "timestamp": "2026-09-04T11:38:38.495Z",
  "payload": {
    "task": {
      "id": "Xi57few2aS8bQ1zL",
      "name": "Ship the API quickstart",
      "…": "…"
    }
  }
}
```

`eventId` is the event's own id, stable across retries and redeliveries.
`triggeredBy` names who caused it and takes one of three shapes:

These ids are not the actor ids returned on `createdByActorId` /
`updatedByActorId`. Do not cross-reference them.

| `source` | Fields |
| --- | --- |
| `human` | `userId`, `teamMemberId` |
| `agent` | `agentId` |
| `system` | none — an automation or a scheduled job |

Read the project from the resource in `payload`.

Membership events carry `payload: { "parentId", "relatedId", "relation" }`, one
delivery per linked id. Operation events carry that operation's response body,
under the same `payload.{resource}` key. `updated` events carry the **full
current resource**, not a diff — compare it with your own copy to see what
moved.

## Verifying deliveries

Every delivery is signed, so you can prove it came from Nifty and has not been
tampered with. Along with the JSON body, each request carries:

| Header | Value |
| --- | --- |
| `X-Webhook-Signature` | `t=<unix seconds>,v1=<hex HMAC>` |
| `X-Webhook-Event` | The event that fired, e.g. `task.created` |
| `X-Webhook-Id` | The id of the webhook registration this came from |
| `X-Webhook-Delivery-Id` | `{webhookId}:{eventId}` — **identical on every retry** |
| `X-Api-Version` | The payload version the webhook is registered for |
| `User-Agent` | `Nifty-Webhooks/2.0` |

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`, hex encoded, where `t` is the
same value sent in the header. Two things to check on every delivery:

- **Recompute the signature over the raw body bytes.** Parsing the JSON and
  re-serializing it changes those bytes, and the signature will not match.
  Compare with a constant-time function — a plain `===` leaks the expected
  signature byte by byte through response timing.
- **Reject a stale `t`.** Signing the timestamp is what makes replay
  detectable, but only if you enforce it: reject anything more than **5
  minutes** away from your own clock, in either direction.

```js
import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 300; // 5 minutes

// `rawBody` must be the exact bytes Nifty sent. In Express, mount
// express.raw({ type: 'application/json' }) on the webhook route.
export function verifyNiftySignature(signatureHeader, rawBody, secret) {
  const parts = Object.fromEntries(
    String(signatureHeader ?? '')
      .split(',')
      .map((part) => part.split('=')),
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const received = Buffer.from(v1, 'hex');
  const computed = Buffer.from(expected, 'hex');

  // timingSafeEqual throws on a length mismatch, so check that first.
  return (
    received.length === computed.length &&
    crypto.timingSafeEqual(received, computed)
  );
}
```

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # 5 minutes

def verify_nifty_signature(signature_header: str, raw_body: bytes, secret: str) -> bool:
    """`raw_body` must be the exact bytes Nifty sent, not a re-serialized dict."""
    parts = dict(
        part.split("=", 1)
        for part in (signature_header or "").split(",")
        if "=" in part
    )
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False

    try:
        age = abs(int(time.time()) - int(t))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{t}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, v1)
```

```ruby
require 'openssl'
require 'rack/utils'

TOLERANCE_SECONDS = 300 # 5 minutes

# `raw_body` must be the exact bytes Nifty sent. In Rails, read
# request.raw_post before any JSON parsing.
def verify_nifty_signature(signature_header, raw_body, secret)
  parts = (signature_header || '').split(',').map { |p| p.split('=', 2) }.to_h
  t, v1 = parts['t'], parts['v1']
  return false if t.nil? || v1.nil?
  return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
  Rack::Utils.secure_compare(expected, v1)
end
```

## Delivery guarantees

- **At least once.** An event is delivered one or more times. A crash between
  sending and recording the acknowledgement re-sends it, so your handler must
  be safe to run twice.
- **De-duplicate on `X-Webhook-Delivery-Id`.** It is `{webhookId}:{eventId}`,
  unique per event and identical on every retry and redelivery. Record the ids
  you have processed and acknowledge a repeat with `200` instead of doing the
  work twice.
- **No ordering guarantee.** Deliveries are dispatched concurrently, and a
  retried event arrives after events that were created later. Use the
  `timestamp` field (the moment the change committed) to order, and treat an
  older `updated` payload as stale rather than applying it.
- **A redelivery is freshly signed.** The retry carries a new `t` and therefore
  a new `X-Webhook-Signature`, so a signature you cached from the first attempt
  will not match the second. Verify each request on its own.

## Delivery and retries

- **HTTPS only.** An `http://` URL is rejected at registration.
- **10 second timeout.** Return a `2xx` as soon as you have durably accepted
  the event, and do the heavy work asynchronously — a slow handler is a failed
  delivery.
- **Redirects are not followed.** A `3xx` response counts as a failure; point
  the webhook at its final URL.
- **1 MiB payload cap.** An event whose serialized payload exceeds 1 MiB is not
  delivered, and counts as a failed delivery.
- **Up to 3 retries** after the first attempt, with increasing backoff of
  roughly 1 second, 10 seconds and 60 seconds. Delays are jittered, so exact
  timing varies.
- **10 consecutive failures disables the webhook.** Its `active` flag flips to
  `false` and it stops receiving events. A successful delivery at any point
  resets the counter, so the threshold means ten in a row, not ten in total.

Any `2xx` is a success. Every other status — and a timeout, a redirect, or a
connection error — is a failure and is retried.

### Re-enabling a disabled webhook

Fix your endpoint first, then turn the webhook back on:

```bash
curl -X PATCH https://openapi.niftypm.com/api/v3/webhooks/{id} \
  -H "Authorization: Bearer nft_user_<prefix>_<secret>" \
  -H "Content-Type: application/json" \
  -d '{"active": true}'
```

Re-enabling **resets `failCount` to 0**, so you get the full ten attempts
again rather than being disabled by the next single failure. Nothing is
redelivered — events that fired while the webhook was disabled are gone, so
reconcile through the API for the window you were down.

### Rotating the secret

```
POST /api/v3/webhooks/{id}/rotate-secret
```

The response carries the new `secret` once, exactly like create.

:::warning
**There is no overlap window.** The old secret stops signing the moment the
rotation commits — the very next delivery is signed with the new one. Deploy
the new secret to your receiver *before* the next event arrives, or accept that
deliveries in between fail signature verification and count toward the
ten-failure auto-disable.
:::

## Testing your endpoint

`POST /api/v3/webhooks/{id}/test` sends a synthetic delivery to the registered
URL through the same pipeline — same signing, same headers, same timeout — so
you can confirm your verification, routing and response time before you depend
on live traffic.

It differs from a real delivery in four ways:

- **The event name is derived from your first subscribed pattern**, with `*`
  replaced by `test` — a webhook subscribed to `task.*` gets `task.test`. A
  webhook with no patterns gets `webhook.test`.
- **The body is a stub**: `payload` is
  `{ "_test": true, "message": "…", "timestamp": "…" }`, not a real resource,
  and `triggeredBy` is `{ "source": "system" }`.
- **One attempt, no retries.** A failure is reported back to you in the
  response instead of being retried, and it does not count toward auto-disable.
- **No delivery is recorded.** The probe is not written to the delivery log.

The delivery id follows the same `{webhookId}:{eventId}` shape, but the event
id is a `test-`-prefixed UUID, so a test delivery can never collide with a real
one in your de-duplication table.

## Delivery log

Nifty records every real delivery attempt — event type, response status,
duration, and the error when it failed — and keeps it for **30 days**. Read it
back with:

```
GET /api/v3/webhooks/{id}/deliveries?limit=25
```

`webhooks:read`. `limit` is 1–100 and defaults to 25; attempts come back newest
first in `{ "data": [...], "limit": 25, "hasMore": true }`. There is no cursor —
`hasMore` means older attempts exist, and a larger `limit` is how you reach
them.

| Field | Value |
| --- | --- |
| `id` | Identifier for this attempt |
| `webhookId` | The webhook the attempt was made for |
| `eventType` | The event that was sent — the same value as `X-Webhook-Event` |
| `status` | `delivered` when your endpoint answered `2xx`, otherwise `failed` |
| `responseStatus` | The status your endpoint returned, or `null` when it never answered |
| `durationMs` | How long the attempt took |
| `error` | Short failure reason (e.g. `HTTP 500`, `Request timed out`), cut to 1 KiB; `null` on success |
| `createdAt` | When the attempt was recorded |

It is a record of *attempts*, not a request/response archive: neither body is
stored. A `/test` probe is not recorded here.

For live health, `failCount` and `lastDeliveredAt` on the webhook itself
(`GET /api/v3/webhooks/{id}`) are still the quickest check.

## Errors

Failed management calls answer with the standard v3 error envelope — a stable
`code`, a human-readable `detail`, and any extra fields (such as `errors[]`
naming the offending field) at the **top level** of the body. Every code is
listed on the [errors page](/errors).

| Situation | Code |
| --- | --- |
| An unknown event name, a bad `url`, an over-long field | `validation_failed` (400) |
| The token lacks `webhooks:*`, or the role lacks `manageWebhooks` | `permission_denied` (403) |
| The webhook does not exist, or belongs to another team | `resource_not_found` (404) |
