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:
Code
New to the API? Start with the quickstart — 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 (nft_user_<...>) or an
OAuth 2.1 access token (nft_oauth_<...>).
Code
Code
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 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. 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.
Code
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.
Code
Code
Code
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 with200instead 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
timestampfield (the moment the change committed) to order, and treat an olderupdatedpayload as stale rather than applying it. - A redelivery is freshly signed. The retry carries a new
tand therefore a newX-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
2xxas 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
3xxresponse 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
activeflag flips tofalseand 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:
Code
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
Code
The response carries the new secret once, exactly like create.
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 bytest— a webhook subscribed totask.*getstask.test. A webhook with no patterns getswebhook.test. - The body is a stub:
payloadis{ "_test": true, "message": "…", "timestamp": "…" }, not a real resource, andtriggeredByis{ "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:
Code
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.
| 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) |