# Webhooks

Receive the platform's notifications as a signed HTTP POST, so your own on-call tooling can act on them.

nesly e-mails you when a deployment fails, an allowance runs low, a backup does not complete, or a project starts returning errors. A webhook delivers the same events to a URL of your own, so the alert reaches the channel your team actually watches instead of one person's inbox.

The switches under [notifications](/docs/account/) govern both. A category you turn off does not reach your webhook either; billing messages, which have no switch, reach both.

## Adding an endpoint

```
nesly webhooks add https://example.com/hooks/nesly
nesly webhooks list
nesly webhooks test whe_…              # send one now and see the answer
nesly webhooks deliveries              # what was sent, and what came back
nesly webhooks roll whe_…              # issue a new signing secret
nesly webhooks remove whe_…
```

Adding an endpoint prints a signing secret. It is shown once and is not recoverable — if you lose it, issue a new one with `webhooks roll`, which invalidates the old one immediately. The console has the same controls under your account.

An account may have up to five endpoints.

## Check it before you need it

`nesly webhooks test`, and the button beside each endpoint in the console, send one signed delivery straight away and tell you what your server answered. Do this once when you set the endpoint up: the alternative is finding out that the URL has a typo, a firewall drops the request, or your proxy strips the signature header — during an outage.

The ping carries`"kind": "test"`, which your receiver should ignore rather than act on. One attempt, no retries: you want the truth about this request, not a summary of three. It is sent whatever your notification switches say, and it works on an endpoint that is switched off — checking that something works before you turn it on is the right order.

Test deliveries appear in the delivery log like any other, and a successful one clears a run of failures.

## What arrives

A POST with a JSON body. The identifiers are stable and always English, including`summary`: your console language changes the e-mail, not this.

```
{
  "id": "ntf_4k2m…",
  "kind": "error_rate_high",
  "created": "2026-09-06T09:12:04Z",
  "tenant": "ten_…",
  "summary": "shop returned an error for 61 of the last 101 requests.",
  "data": {
    "project": "shop",
    "requests": 101,
    "failed": 61,
    "route": "/api/checkout",
    "status": 502
  }
}
```

Fields inside `data`depend on the event and are omitted when they do not apply. Treat unknown fields and unknown kinds as things to ignore rather than errors: new kinds are added over time.

<table class="w-full border-collapse text-sm"><tbody><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 font-medium whitespace-nowrap text-neutral-900 dark:text-neutral-100">Nesly-Event</td><td class="py-2">What happened: deploy_failed, usage_warning, error_rate_high, and so on.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 font-medium whitespace-nowrap text-neutral-900 dark:text-neutral-100">Nesly-Delivery</td><td class="py-2">The notification's id. The same value appears as id in the body and in your account's notification list.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 font-medium whitespace-nowrap text-neutral-900 dark:text-neutral-100">Nesly-Timestamp</td><td class="py-2">When the request was signed, in Unix seconds.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 font-medium whitespace-nowrap text-neutral-900 dark:text-neutral-100">Nesly-Signature</td><td class="py-2">v1= followed by the hex HMAC-SHA256 described below.</td></tr></tbody></table>

## Verifying the signature

Anyone can post to your URL. The signature is how you know a request came from nesly, so check it before you act on the contents.

The signed material is the timestamp, a full stop, and the exact bytes of the body:`HMAC-SHA256(secret, timestamp + "." + body)`. Use the raw body, before any JSON parsing — re-serialising changes the bytes and the signature will not match.

```
// Node
import crypto from "node:crypto"

export function verify(secret, headers, rawBody) {
  const ts = headers["nesly-timestamp"]
  const got = headers["nesly-signature"]           // "v1=<hex>"
  // Reject anything older than five minutes: the timestamp is inside the
  // signed material, so it cannot be rewritten without the secret, which
  // is what makes this check worth doing.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const want = "v1=" + crypto
    .createHmac("sha256", secret)
    .update(ts + "." + rawBody)
    .digest("hex")
  // Constant time: a plain === leaks how much of a forged signature was
  // right, which is enough to build the rest of it.
  return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))
}
```
```
# Python
import hashlib, hmac, time

def verify(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers["Nesly-Timestamp"]
    got = headers["Nesly-Signature"]
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(secret.encode(), (ts + ".").encode() + raw_body, hashlib.sha256)
    return hmac.compare_digest(got, "v1=" + mac.hexdigest())
```

Answer with any 2xx once you have stored the event. Everything else is treated as a failure.

## Retries, and what counts as delivered

A 5xx or a connection that fails is retried twice more, one second and then five seconds later. A 4xx is not retried: your server understood the request and rejected it, and sending it again would change nothing. Each attempt is given ten seconds to answer.

Only a 2xx counts as delivered. Redirects are followed at most three times and must stay on https; a redirect chain that ends anywhere else is a failure, not a delivery.

Every attempt is recorded for seven days, with the status your server answered.`nesly webhooks deliveries`and the console both show them, and an endpoint's page says how many attempts in a row have failed.

Deliveries are at-least-once in principle: use`id` to recognise an event you have already handled.

## Addresses we will not post to

Your endpoint is reached from inside our network, so it has to be somewhere on the public internet. These are not allowed when you add them, and checked again on every attempt against the address actually being connected to — a name that later resolves somewhere private is rejected at that moment.

<table class="w-full border-collapse text-sm"><tbody><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 align-top text-neutral-900 dark:text-neutral-100">Anything that is not https</td><td class="py-2">The payload and its signature would travel in clear.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 align-top text-neutral-900 dark:text-neutral-100">Loopback and private addresses</td><td class="py-2">127.0.0.1, 10.x, 172.16–31.x, 192.168.x, fd00::/7 and the IPv6 forms of all of them.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 align-top text-neutral-900 dark:text-neutral-100">Link-local addresses</td><td class="py-2">169.254.x, including the cloud metadata address, and fe80::/10.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 align-top text-neutral-900 dark:text-neutral-100">Carrier-grade NAT</td><td class="py-2">100.64.0.0/10.</td></tr><tr class="border-b border-neutral-200 align-top dark:border-neutral-800"><td class="py-2 pr-6 align-top text-neutral-900 dark:text-neutral-100">Any port but 443</td><td class="py-2">https is the only scheme accepted, so 443 is the only port reached — including after a redirect.</td></tr></tbody></table>

---
Canonical: https://nesly.net/docs/webhooks/
Markdown: Accept: text/markdown on the HTML URL, or replace the trailing slash with .md
