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

# Webhooks

> Receive real-time scan events via HTTP callbacks.

Webhooks push scan data to your server in real time. Every time a node is scanned, NearNode fires an HTTP POST to your registered endpoint.

<Note>
  Webhooks are delivered with **at-least-once** semantics. Your endpoint should be idempotent — use the `event_id` to deduplicate.
</Note>

## Setting Up a Webhook

Register a webhook URL in the NearNode Console under **Settings → Webhooks**, or via the API.

<Steps>
  <Step title="Add your endpoint URL">
    Enter the HTTPS URL where you want to receive events (e.g., `https://api.yourapp.com/webhooks/nearnode`).
  </Step>

  <Step title="Select event types">
    Choose which events to subscribe to. You can listen to all events or filter by type.
  </Step>

  <Step title="Copy your signing secret">
    A unique secret is generated for each webhook. Store it securely — you'll use it to verify signatures.
  </Step>

  <Step title="Test the connection">
    Click **Send Test Event** to verify your endpoint responds with a `200` status.
  </Step>
</Steps>

## Event Types

<Tabs>
  <Tab title="Scan Events" icon="qrcode">
    Fired when a node is scanned by an end user.

    | Event                 | Description                            |
    | --------------------- | -------------------------------------- |
    | `scan.created`        | A node was scanned (any function type) |
    | `scan.redirect`       | A redirect was followed                |
    | `scan.vcard_download` | A vCard was downloaded                 |
    | `scan.blocked`        | Scan was blocked by kill switch        |

    ```json theme={null}
    {
      "event": "scan.created",
      "event_id": "evt_a1b2c3d4",
      "timestamp": "2026-02-09T14:30:00.000Z",
      "data": {
        "node_slug": "o1ulpplq",
        "node_id": "node_abc123...",
        "city": "Zurich",
        "country": "CH",
        "device_type": "mobile",
        "user_agent": "Mozilla/5.0 (iPhone; ...)",
        "ip_address": "203.0.113.42",
        "variant": null,
        "session_id": "sid_x7y8z9"
      }
    }
    ```
  </Tab>

  <Tab title="Node Events" icon="circle-nodes">
    Fired when a node's configuration changes.

    | Event              | Description                        |
    | ------------------ | ---------------------------------- |
    | `node.created`     | A new node was created             |
    | `node.updated`     | A node's payload or status changed |
    | `node.deleted`     | A node was permanently deleted     |
    | `node.deactivated` | Kill switch was triggered          |

    ```json theme={null}
    {
      "event": "node.updated",
      "event_id": "evt_e5f6g7h8",
      "timestamp": "2026-02-09T15:00:00.000Z",
      "data": {
        "node_id": "node_abc123...",
        "node_slug": "o1ulpplq",
        "changes": {
          "redirect_url": "https://acme.com/new-page",
          "status": "active"
        }
      }
    }
    ```
  </Tab>

  <Tab title="Batch Events" icon="boxes-stacked">
    Fired during batch lifecycle transitions.

    | Event             | Description                            |
    | ----------------- | -------------------------------------- |
    | `batch.completed` | Batch generation finished              |
    | `batch.deployed`  | All tags in batch written and verified |
    | `batch.failed`    | Batch generation or deployment failed  |

    ```json theme={null}
    {
      "event": "batch.completed",
      "event_id": "evt_i9j0k1l2",
      "timestamp": "2026-02-09T16:00:00.000Z",
      "data": {
        "batch_id": "batch_xyz789...",
        "label": "Q1 2026 Business Cards",
        "quantity": 100,
        "status": "ready"
      }
    }
    ```
  </Tab>
</Tabs>

## Retry Policy

Failed deliveries (non-2xx response) are retried with exponential backoff:

| Attempt   | Delay      | Total elapsed |
| --------- | ---------- | ------------- |
| 1st retry | 1 minute   | 1 min         |
| 2nd retry | 5 minutes  | 6 min         |
| 3rd retry | 30 minutes | 36 min        |

<Warning>
  After 3 consecutive failures, the webhook is marked as **failing**. It remains active but a warning is shown in the console. Fix your endpoint and click **Retry Failed** to re-deliver missed events.
</Warning>

## Signature Verification

Every webhook request includes an `X-NearNode-Signature` header. Verify it to ensure the request is authentic and hasn't been tampered with.

The signature is an HMAC-SHA256 of the raw request body using your webhook signing secret.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(rawBody, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // Express middleware example
  app.post('/webhooks/nearnode', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-nearnode-signature'];
    const secret = process.env.NEARNODE_WEBHOOK_SECRET;

    if (!verifyWebhook(req.body, signature, secret)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body);
    console.log(`Received ${event.event} for ${event.data.node_slug}`);

    res.status(200).send('OK');
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  from flask import Flask, request, abort

  app = Flask(__name__)

  def verify_webhook(payload, signature, secret):
      expected = hmac.new(
          secret.encode(),
          payload,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  @app.route('/webhooks/nearnode', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-NearNode-Signature')
      secret = os.environ['NEARNODE_WEBHOOK_SECRET']

      if not verify_webhook(request.data, signature, secret):
          abort(401)

      event = request.get_json()
      print(f"Received {event['event']} for {event['data']['node_slug']}")

      return 'OK', 200
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "io"
      "net/http"
      "os"
  )

  func verifyWebhook(body []byte, signature, secret string) bool {
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(body)
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(signature), []byte(expected))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      signature := r.Header.Get("X-NearNode-Signature")
      secret := os.Getenv("NEARNODE_WEBHOOK_SECRET")

      if !verifyWebhook(body, signature, secret) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

<Tip>
  Always use **timing-safe comparison** (like `crypto.timingSafeEqual` or `hmac.compare_digest`) to prevent timing attacks on signature verification.
</Tip>

## Best Practices

<AccordionGroup>
  <Accordion title="Respond quickly">
    Return a `200` response immediately, then process the event asynchronously. Webhook delivery times out after **10 seconds** — long-running processing will trigger retries.
  </Accordion>

  <Accordion title="Handle duplicates">
    Use the `event_id` field to deduplicate. Network issues or retries may deliver the same event more than once.
  </Accordion>

  <Accordion title="Use HTTPS">
    Webhook URLs must use HTTPS. Plain HTTP endpoints are rejected during registration.
  </Accordion>

  <Accordion title="Monitor delivery">
    Check the **Webhooks** page in the console for delivery logs, response codes, and latency metrics. Set up an [alert rule](/concepts/nodes#routing-rules) to notify you if deliveries start failing.
  </Accordion>
</AccordionGroup>
