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

# Receive Nozle webhooks

> Webhooks Nozle sends to your endpoint: how to register one, verify the signature, and handle retries

Nozle sends a JSON `POST` to your endpoint when something changes, for example `subscription.started` or `invoice.payment_status_updated`. This page is about those webhooks. For the events Stripe and Razorpay send to Nozle, see [Webhooks](/api/webhooks).

## Register an endpoint

Create an endpoint in the dashboard under **Developers > Webhooks**, or with `POST /webhook_endpoints`:

```json theme={null}
{
  "webhook_endpoint": {
    "webhook_url": "https://app.example.com/nozle/webhooks",
    "signature_algo": "hmac"
  }
}
```

`signature_algo` is `jwt` (the default) or `hmac`. The URL must be valid and each URL can be registered once per organization. The dashboard form selects `hmac` by default, while the API uses `jwt` when you send no value. The API reference has a Webhook Endpoints section.

Until January 19, 2027, payloads also carry the old, former-prefix key names beside `nozle_id` and the new keys. Read the new keys: see [legacy key names](/api/api-standards#legacy-key-names-removal-january-19-2027).

Each delivery is a JSON body with a `webhook_type` such as `subscription.started` and the object it is about, for example `subscription`. The event types are listed in the API reference under Webhooks.

A failed credit note refund is sent as `credit_note.refund_failure`. To receive only some types, send an `event_types` array when you create or update the endpoint (no array means every type). `credit_note.refund_failure` is accepted there; the former name `credit_note.provider_refund_failure` is still accepted and saved as `credit_note.refund_failure`, and endpoints saved with the former name are moved to it.

## Headers

| Header | Value |
| - | - |
| `X-Nozle-Signature` | The signature of the body, in the format set by `signature_algo`. |
| `X-Nozle-Signature-Algorithm` | `jwt` or `hmac`. |
| `X-Nozle-Unique-Key` | The delivery ID. It is the same on every attempt of one webhook. |

## Verify the signature

Verify the **raw request body** before you parse it. A framework that parses and re-serialises the JSON can change the bytes and break the check.

### With the SDK

The server SDKs check both formats and return the parsed event. For `jwt` they read Nozle's public key once with your secret key and cache it. For `hmac`, pass the signing secret. Webhook verification requires `@nozle-js/node` `0.13.0`, `nozle-sdk` `0.13.0` or `github.com/nozle-dev/nozle-go` `v0.12.0`.

**Node.js**

```ts theme={null}
import { Nozle, createWebhookHandler } from '@nozle-js/node'

const nozle = new Nozle()

// Next.js route handler, Remix, Hono, Bun or Deno: answers 200, 400 when the request is not genuine,
// 405 for a method other than POST, or 500 when onEvent throws.
export const POST = createWebhookHandler(nozle, {
  hmacKey: process.env.NOZLE_WEBHOOK_HMAC_KEY, // only for hmac endpoints
  onEvent: async (event) => {
    if (await alreadyHandled(event.id)) return
    if (event.type === 'subscription.started') await activate(event.object)
  },
})
```

Elsewhere, call `nozle.webhooks.verify({ body, headers, hmacKey })` with the raw body; it throws `WebhookVerificationError` with a `code`.

**Python**

```python theme={null}
from nozle import Nozle, WebhookVerificationError

nozle = Nozle()

@app.post("/nozle/webhooks")  # Flask
def nozle_webhook():
    try:
        event = nozle.webhooks.verify(request.get_data(), request.headers, hmac_key=HMAC_KEY)
    except WebhookVerificationError as error:
        return {"code": error.code}, 400
    if event.type == "subscription.started":
        activate(event.object)
    return {"received": True}
```

**Go**

```go theme={null}
import nozle "github.com/nozle-dev/nozle-go"

client := nozle.New("") // reads NOZLE_SECRET_KEY
http.Handle("/nozle/webhooks", client.WebhookHandler(hmacKey, func(ctx context.Context, e nozle.WebhookEvent) error {
	if e.Type == "subscription.started" {
		return activate(ctx, e.Object) // an error answers 500, so Nozle tries again
	}
	return nil
}))
```

The handler answers 200, 400 when the request is not genuine, 405 for a method other than POST, or 500 when `onEvent` returns an error or Nozle's public key cannot be read. Or call `client.VerifyWebhook(ctx, body, r.Header, hmacKey)`; a forged request returns a `*nozle.WebhookVerificationError`.

The event has `id` (the `X-Nozle-Unique-Key`, for skipping duplicates), `type`, `objectType` (Python `object_type`), `organizationId`, `object` and the whole `payload`. A failed check has the code `missing_signature`, `unsupported_algorithm`, `missing_hmac_key`, `invalid_signature` or `invalid_payload`.

### Without the SDK

### `hmac`

The signature is `Base64(HMAC-SHA256(hmac_key, raw body))`. The `hmac_key` is your organization's signing secret, shown on the **Developers > Webhooks** page of the dashboard.

**Node.js**

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'

function isValid(rawBody: Buffer, signature: string, hmacKey: string): boolean {
  const expected = createHmac('sha256', hmacKey).update(rawBody).digest()
  const received = Buffer.from(signature, 'base64')
  return received.length === expected.length && timingSafeEqual(received, expected)
}
```

**Python**

```python theme={null}
import base64, hashlib, hmac

def is_valid(raw_body: bytes, signature: str, hmac_key: str) -> bool:
    expected = hmac.new(hmac_key.encode(), raw_body, hashlib.sha256).digest()
    return hmac.compare_digest(base64.b64decode(signature), expected)
```

**Go**

```go theme={null}
func isValid(rawBody []byte, signature, hmacKey string) bool {
	received, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, []byte(hmacKey))
	mac.Write(rawBody)
	return hmac.Equal(received, mac.Sum(nil))
}
```

### `jwt`

The signature is an RS256 JWT. Its claims are `data`, the payload as a JSON string, and `iss`. Fetch the public key from `GET /webhooks/json_public_key` with your secret key. The response is `{"webhook": {"public_key": "..."}}`, where the key is the PEM text encoded in Base64.

```ts theme={null}
import jwt from 'jsonwebtoken'

const pem = Buffer.from(publicKeyBase64, 'base64').toString('utf8')
const claims = jwt.verify(signature, pem, { algorithms: ['RS256'] }) as { data: string }
const isGenuine = claims.data === rawBody.toString('utf8')
```

Cache the key; it changes only when Nozle rotates it. The older `GET /webhooks/public_key` returns the same key as plain text and is deprecated.

## Webhooks to the Engine

An endpoint that points at Nozle's Engine (`/webhooks/nozle`) is verified with the sending organization's own signing key, the same `hmac_key` as above. It must use the `hmac` signature algorithm: `jwt` endpoints are rejected. An unsigned, forged or cross-organization request, or one whose organization is unknown, gets `401` in every environment and nothing is processed. If Nozle cannot read the key, the Engine answers `503` and Core retries. A signed webhook that names another organization's subscription does not create that subscription's credit grants.

## Delivery, retries and ordering

* Respond with a `2xx` within 30 seconds. Any other status, a timeout or a connection error is a failed attempt.
* Nozle makes up to 3 attempts, the second about 3 seconds after the first and the third about 20 seconds after the second. After the third failure the webhook is marked failed and is not sent again.
* Delivery is at least once. Deduplicate on `X-Nozle-Unique-Key`.
* Order is not guaranteed. Treat a payload as the state of the object at that moment and read the object from the API when you need the latest.
* The signature does not contain a timestamp, so a replayed delivery cannot be rejected by its age. The unique key is what protects you from handling one twice.
* Return `200` for event types you do not handle, so they are not retried.

Nozle keeps delivery records for 90 days.


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