# Webhooks

Stay informed about important events in your Duro library with real-time webhook notifications.

{% hint style="info" %}
Webhooks follow a **"Ping then Pull"** pattern - you receive lightweight event notifications with key metadata, then fetch full resource details using our GraphQL API when needed.
{% endhint %}

## Overview

Duro webhooks let your applications receive real-time notifications when important events occur in your library. Instead of constantly polling for changes, webhooks push event notifications directly to your specified endpoints, helping you build responsive integrations that react immediately to data changes.

### Key Benefits

* **Real-time updates** - Get notified instantly when data changes
* **Efficient integration** - No need for constant API polling
* **Selective subscriptions** - Subscribe only to the events you care about
* **Reliable delivery** - Built-in retry mechanisms with exponential backoff
* **Secure** - HMAC-SHA256 signature verification to ensure authenticity

## How Webhooks Work

1. **Event occurs** - Something happens in Duro (e.g., a component is updated)
2. **Notification sent** - Duro sends a lightweight JSON payload to your webhook URL
3. **Fetch full data** - Your application uses the provided metadata to fetch complete details via GraphQL as needed

This pattern keeps webhook payloads small and fast while giving you access to all the data you need.

***

## Available Events

Webhooks are scoped to a specific **library**. Each webhook can subscribe to one or more event types.

### Component Events

These events fire when components in your library are created, updated, or deleted:

| GraphQL Enum        | Payload Value        | Description                                |
| ------------------- | -------------------- | ------------------------------------------ |
| `COMPONENT_CREATED` | `components.created` | A new component was created in the library |
| `COMPONENT_UPDATED` | `components.updated` | An existing component was modified         |
| `COMPONENT_DELETED` | `components.deleted` | A component was removed from the library   |

{% hint style="info" %}
When subscribing to events via GraphQL, use the **enum name** (e.g., `COMPONENT_CREATED`). When processing webhook payloads, the `event` field contains the **string value** (e.g., `components.created`).
{% endhint %}

### Change Order Events

Track the full lifecycle of change orders in your library with these events:

| GraphQL Enum                           | Payload Value                           | Description                                                  |
| -------------------------------------- | --------------------------------------- | ------------------------------------------------------------ |
| `CHANGE_ORDER_OPENED`                  | `change_orders.opened`                  | A change order transitioned from draft to open status        |
| `CHANGE_ORDER_UPDATED`                 | `change_orders.updated`                 | Change order details were modified (name, description, etc.) |
| `CHANGE_ORDER_DELETED`                 | `change_orders.deleted`                 | A change order was archived                                  |
| `CHANGE_ORDER_STAGE_TRANSITION`        | `change_orders.stage_transition`        | Change order moved to a different workflow stage             |
| `CHANGE_ORDER_STAGE_REVIEWER_DECISION` | `change_orders.stage_reviewer_decision` | A reviewer approved or rejected their stage                  |
| `CHANGE_ORDER_RESOLUTION`              | `change_orders.resolution`              | Change order was fully approved, rejected, or withdrawn      |

{% hint style="info" %}
**Semantic Event Priority**: When a change triggers multiple events (e.g., updating status from draft to open), Duro sends the more specific semantic event (`CHANGE_ORDER_OPENED`) rather than the generic `CHANGE_ORDER_UPDATED`. This prevents duplicate notifications and makes event handling more predictable.
{% endhint %}

***

## Setting Up Webhooks

### Create a Webhook

To create a webhook, you'll need:

* The `x-library` header set to specify which library to monitor
* A `url` - your HTTPS endpoint that will receive notifications
* A list of `events` - which event types to subscribe to

{% hint style="info" %}
**Required Headers**: All webhook operations require the `x-api-key`, `x-organization`, and `x-library` headers. The webhook will be created for the library specified in the `x-library` header.
{% endhint %}

```graphql
mutation CreateWebhook {
  webhooks {
    create(input: {
      name: "ERP Sync Webhook"
      description: "Syncs component updates to our ERP system"
      url: "https://api.yourcompany.com/webhooks/duro"
      events: [COMPONENT_CREATED, COMPONENT_UPDATED]
      signingSecret: "your-secret-key-min-16-chars"
      timeoutSeconds: 30
      maxRetries: 3
      isEnabled: true
    }) {
      id
      name
      url
      events
      isEnabled
      createdAt
    }
  }
}
```

#### Configuration Options

| Field            | Required  | Description                                                            | Default |
| ---------------- | --------- | ---------------------------------------------------------------------- | ------- |
| `name`           | Yes       | Unique name for this webhook (1-100 characters)                        | -       |
| `url`            | Yes       | HTTPS endpoint URL (must be valid HTTPS)                               | -       |
| `events`         | Yes       | Array of event types to subscribe to                                   | -       |
| `description`    | No        | Optional description (max 500 characters)                              | `null`  |
| `signingSecret`  | No        | Secret for HMAC signature verification (min 16 characters if provided) | `null`  |
| `timeoutSeconds` | No        | Request timeout in seconds (5-300)                                     | `30`    |
| `maxRetries`     | No        | Maximum retry attempts on failure (0-10)                               | `3`     |
| `isEnabled`      | No        | Whether the webhook is active                                          | `true`  |
| `isArchived`     | Read-only | Whether the webhook has been archived (set via `archive` mutation)     | `false` |

### List Your Webhooks

Retrieve all webhooks for the library specified in your `x-library` header:

```graphql
query GetMyWebhooks {
  webhooks {
    findAll {
      id
      name
      description
      url
      events
      isEnabled
      isArchived
      timeoutSeconds
      maxRetries
      createdAt
      updatedAt
    }
  }
}
```

{% hint style="info" %}
The `findAll` query returns webhooks for the library specified in the `x-library` header. Only active (non-archived) webhooks are returned.
{% endhint %}

### Get a Specific Webhook

```graphql
query GetWebhook {
  webhooks {
    findOne(id: "webhook-uuid") {
      id
      name
      description
      url
      events
      isEnabled
      isArchived
      signingSecret
      timeoutSeconds
      maxRetries
      createdAt
    }
  }
}
```

{% hint style="info" %}
The `findOne` query returns a `webhook_not_found` error if the webhook has been archived.
{% endhint %}

### Update a Webhook

You can update any webhook configuration field:

```graphql
mutation UpdateWebhook {
  webhooks {
    update(
      id: "webhook-uuid"
      input: {
        name: "Updated Webhook Name"
        events: [COMPONENT_CREATED, COMPONENT_UPDATED, COMPONENT_DELETED]
        isEnabled: false
        maxRetries: 5
      }
    ) {
      id
      name
      events
      isEnabled
      maxRetries
    }
  }
}
```

### Add Events to a Webhook

Add additional event subscriptions without removing existing ones:

```graphql
mutation AddWebhookEvents {
  webhooks {
    addEvents(
      id: "webhook-uuid"
      input: {
        events: [COMPONENT_DELETED]
      }
    ) {
      id
      events
    }
  }
}
```

### Remove Events from a Webhook

Remove specific event subscriptions:

```graphql
mutation RemoveWebhookEvents {
  webhooks {
    removeEvents(
      id: "webhook-uuid"
      input: {
        events: [COMPONENT_DELETED]
      }
    ) {
      id
      events
    }
  }
}
```

### Archive a Webhook

When you no longer need a webhook but want to preserve its configuration history, you can archive it instead of deleting it. Archived webhooks:

* **Stop receiving events** - No new event deliveries will be attempted
* **Are hidden from queries** - Won't appear in `findAll` or `findOne` results
* **Free up the name** - You can create a new webhook with the same name
* **Cannot be unarchived** - This action is permanent

```graphql
mutation ArchiveWebhook {
  webhooks {
    archive(id: "webhook-uuid") {
      id
      name
      isArchived
    }
  }
}
```

{% hint style="warning" %}
Archiving a webhook is permanent and cannot be undone. If you need to temporarily stop webhook deliveries, consider using the `update` mutation to set `isEnabled: false` instead.
{% endhint %}

#### When to Archive vs. Disable

| Action                           | Use Case                                                                      |
| -------------------------------- | ----------------------------------------------------------------------------- |
| **Disable** (`isEnabled: false`) | Temporarily pause deliveries; webhook remains queryable and can be re-enabled |
| **Archive**                      | Permanently retire a webhook; frees up the name for reuse                     |

***

## Webhook Payloads

All webhook notifications follow a consistent JSON structure:

```json
{
  "event": "components.updated",
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "sourceId": "nats-msg-12345",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "metadata": {
    "componentId": "comp-abc123-uuid",
    "revisionValue": "1.A",
    "version": 1
  }
}
```

### Payload Fields Explained

| Field       | Type   | Description                                        |
| ----------- | ------ | -------------------------------------------------- |
| `event`     | string | The event type (e.g., `components.created`)        |
| `eventId`   | string | Unique identifier for this webhook delivery (UUID) |
| `sourceId`  | string | Internal event ID for tracking and debugging       |
| `timestamp` | string | ISO 8601 timestamp when the event occurred         |
| `metadata`  | object | Event-specific data (see below)                    |

### Component Event Metadata

For component events, the `metadata` object contains:

| Field           | Type   | Description                                 |
| --------------- | ------ | ------------------------------------------- |
| `componentId`   | string | UUID of the affected component              |
| `revisionValue` | string | Current revision value (e.g., "1.A", "2.B") |
| `version`       | number | Current version number                      |

#### Example: Component Created

```json
{
  "event": "components.created",
  "eventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "sourceId": "nats-msg-98765",
  "timestamp": "2025-01-15T14:22:33.456Z",
  "metadata": {
    "componentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "revisionValue": "1.A",
    "version": 1
  }
}
```

#### Example: Component Updated

```json
{
  "event": "components.updated",
  "eventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
  "sourceId": "nats-msg-54321",
  "timestamp": "2025-01-15T15:45:12.789Z",
  "metadata": {
    "componentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "revisionValue": "1.B",
    "version": 2
  }
}
```

### Change Order Event Metadata

Each change order event type includes different metadata fields based on the event context.

#### `change_orders.opened`

Fired when a change order transitions from `draft` to `open` status (submitted for review).

| Field           | Type   | Description               |
| --------------- | ------ | ------------------------- |
| `changeOrderId` | string | UUID of the change order  |
| `status`        | string | New status value (`open`) |

```json
{
  "event": "change_orders.opened",
  "eventId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
  "sourceId": "nats-msg-11111",
  "timestamp": "2025-01-15T09:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "open"
  }
}
```

#### `change_orders.updated`

Fired when change order details are modified (name, description, etc.) without triggering a semantic event like `opened` or `resolution`.

| Field           | Type   | Description              |
| --------------- | ------ | ------------------------ |
| `changeOrderId` | string | UUID of the change order |

```json
{
  "event": "change_orders.updated",
  "eventId": "d4e5f6a7-b8c9-0123-def0-456789012345",
  "sourceId": "nats-msg-22222",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

#### `change_orders.deleted`

Fired when a change order is archived.

| Field           | Type   | Description              |
| --------------- | ------ | ------------------------ |
| `changeOrderId` | string | UUID of the change order |

```json
{
  "event": "change_orders.deleted",
  "eventId": "e5f6a7b8-c9d0-1234-ef01-567890123456",
  "sourceId": "nats-msg-33333",
  "timestamp": "2025-01-15T11:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

#### `change_orders.stage_transition`

Fired when a change order moves between workflow stages.

| Field              | Type   | Description                                                          |
| ------------------ | ------ | -------------------------------------------------------------------- |
| `changeOrderId`    | string | UUID of the change order                                             |
| `previousStageId`  | string | UUID of the previous stage (omitted if starting workflow)            |
| `newStageId`       | string | UUID of the new stage (omitted if resolved/completed)                |
| `transitionReason` | string | Reason for the transition (e.g., `stage_approved`, `stage_rejected`) |

```json
{
  "event": "change_orders.stage_transition",
  "eventId": "f6a7b8c9-d0e1-2345-f012-678901234567",
  "sourceId": "nats-msg-44444",
  "timestamp": "2025-01-15T12:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "previousStageId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "newStageId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
    "transitionReason": "stage_approved"
  }
}
```

#### `change_orders.stage_reviewer_decision`

Fired when a reviewer makes a decision (approve/reject) on their assigned stage.

| Field           | Type   | Description                                  |
| --------------- | ------ | -------------------------------------------- |
| `changeOrderId` | string | UUID of the change order                     |
| `stageId`       | string | UUID of the stage being reviewed             |
| `reviewerId`    | string | UUID of the reviewer who made the decision   |
| `decision`      | string | The decision made (`approved` or `rejected`) |

```json
{
  "event": "change_orders.stage_reviewer_decision",
  "eventId": "a7b8c9d0-e1f2-3456-0123-789012345678",
  "sourceId": "nats-msg-55555",
  "timestamp": "2025-01-15T13:30:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "stageId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "reviewerId": "d4e5f6a7-b8c9-0123-def0-456789012345",
    "decision": "approved"
  }
}
```

#### `change_orders.resolution`

Fired when a change order reaches a final resolution (approved, rejected, or withdrawn).

| Field           | Type   | Description                                               |
| --------------- | ------ | --------------------------------------------------------- |
| `changeOrderId` | string | UUID of the change order                                  |
| `resolution`    | string | Final resolution (`approved`, `rejected`, or `withdrawn`) |

```json
{
  "event": "change_orders.resolution",
  "eventId": "b8c9d0e1-f2a3-4567-1234-890123456789",
  "sourceId": "nats-msg-66666",
  "timestamp": "2025-01-15T14:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "resolution": "approved"
  }
}
```

### HTTP Headers

Each webhook request includes these headers:

| Header                | Value                                                                                                                             |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                                                |
| `User-Agent`          | `Duro-Webhook-Service/1.0`                                                                                                        |
| `X-Webhook-Signature` | Versioned HMAC-SHA256 signature in the form `sha256=<hex digest>` (only sent when a `signingSecret` is configured on the webhook) |

***

## Signing Secrets

Every Duro webhook can carry a **signing secret** that lets your endpoint cryptographically verify each delivery actually came from Duro and was not modified in transit. Treat the signing secret like any other credential — it is the only thing protecting your endpoint from spoofed requests.

### How Signing Works

When a webhook has a `signingSecret`, Duro signs every outbound delivery as follows:

1. Compute `HMAC_SHA256(rawRequestBody, signingSecret)` and hex-encode the digest.
2. Send the result in the `X-Webhook-Signature` header, prefixed with the algorithm version.

The header value uses a versioned, Stripe-style format:

```
X-Webhook-Signature: sha256=<hex digest>
```

The `sha256=` prefix is reserved for future algorithm upgrades — your verification code should split on `=` and reject any prefix it does not understand rather than assume a bare hex digest.

{% hint style="info" %}
The signature is computed over the **exact bytes of the request body** that Duro sent. Re-serializing the JSON (for example by parsing and re-stringifying it) will produce a different signature and verification will fail. Always verify against the raw body.
{% endhint %}

### Providing a Signing Secret

The `signingSecret` field is **optional** on `webhooks.create` and `webhooks.update`:

* If you provide a `signingSecret`, Duro stores the value you provided (minimum 16 characters).
* If you omit `signingSecret` on create, Duro **generates a cryptographically secure signing secret for you by default**. You can read it back via the `findOne` query.
* If a webhook has no signing secret configured at all, deliveries are sent unsigned and the `X-Webhook-Signature` header is omitted.

Because Duro auto-generates a signing secret on create, in practice every new webhook is signed unless you have deliberately cleared the secret.

### Verifying Signatures

Your endpoint should:

1. Read the **raw request body bytes** before any JSON parsing.
2. Read the `X-Webhook-Signature` header and split it into algorithm + digest on the first `=`.
3. Compute `HMAC_SHA256(rawBody, signingSecret)` and hex-encode it.
4. Compare the computed digest to the digest from the header using a **timing-safe comparison**.
5. Reject the request if the header is missing, the algorithm is unknown, or the digests do not match.

#### Verifying Signatures (Node.js)

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, headerValue, secret) {
  if (!headerValue) return false;

  // Header format is "sha256=<hex digest>". Reject anything else.
  const [algorithm, providedHex] = headerValue.split('=', 2);
  if (algorithm !== 'sha256' || !providedHex) return false;

  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const providedBuf = Buffer.from(providedHex, 'hex');
  const expectedBuf = Buffer.from(expectedHex, 'hex');

  // timingSafeEqual throws if the buffer lengths differ, so guard first.
  if (providedBuf.length !== expectedBuf.length) return false;

  return crypto.timingSafeEqual(providedBuf, expectedBuf);
}

// Express.js middleware example. The `express.raw` parser is critical here —
// signature verification must run against the unparsed request body.
app.post('/webhooks/duro', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = req.body; // Buffer

  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Signature verified - safe to parse and process.
  const payload = JSON.parse(rawBody.toString('utf8'));
  // ... handle the event

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

#### Verifying Signatures (Python)

```python
import hmac
import hashlib

def verify_webhook_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    if not header_value or "=" not in header_value:
        return False

    algorithm, _, provided_hex = header_value.partition("=")
    if algorithm != "sha256" or not provided_hex:
        return False

    expected_hex = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    # compare_digest handles length mismatches safely.
    return hmac.compare_digest(provided_hex, expected_hex)

# Flask example
import os
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks/duro', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')

    if not verify_webhook_signature(
        request.get_data(),  # raw bytes, not request.get_json()
        signature,
        os.environ['WEBHOOK_SECRET'],
    ):
        abort(401)

    payload = request.get_json()
    # ... handle the event

    return 'OK', 200
```

### Endpoint Hardening Checklist

* **Reject unsigned requests when you expect signatures.** If your webhook has a signing secret configured, treat a missing `X-Webhook-Signature` header as an authentication failure (`401`).
* **Verify against the raw body.** Parsing JSON before verifying will change the byte sequence and break the signature.
* **Use a constant-time comparison.** Use `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python) — never `===` / `==`.
* **Pin the algorithm.** Refuse any header that does not start with `sha256=` so a future algorithm migration is explicit.
* **Dedupe with `eventId`.** Signature verification proves authenticity, not uniqueness — combine it with idempotent processing keyed on `eventId`.

### Security Best Practices

* **Always use HTTPS** - Webhook URLs must use HTTPS (enforced by Duro)
* **Verify signatures** - Always validate the `X-Webhook-Signature` header
* **Use timing-safe comparison** - Prevent timing attacks when comparing signatures
* **Keep secrets secure** - Store your `signingSecret` in environment variables, never in code
* **Rotate secrets periodically** - Update your signing secret via `webhooks.update` regularly

***

## Fetching Full Resource Data

After receiving a webhook notification, use the provided IDs to fetch complete resource details via GraphQL.

### Fetch Component Details

```graphql
query GetComponentDetails($componentId: ID!) {
  components {
    get(filter: { ids: [$componentId] }) {
      connection {
        edges {
          node {
            id
            cpn
            name
            description
            revision
            status
            category {
              name
              code
            }
            sources {
              manufacturer
              mpn
            }
            specs {
              name
              value
              unit
            }
          }
        }
      }
    }
  }
}
```

### Fetch Change Order Details

```graphql
query GetChangeOrderDetails($changeOrderId: ID!) {
  changeOrders {
    get(id: $changeOrderId) {
      id
      name
      description
      status
      resolution
      createdAt
      updatedAt
      stage {
        id
        name
        order
      }
      items {
        id
        component {
          id
          cpn
          name
        }
        action
      }
      reviewers {
        id
        user {
          id
          email
          name
        }
        decision
        decidedAt
      }
    }
  }
}
```

### Example: Complete Webhook Handler

Here's a complete example showing how to receive a webhook and fetch the full component data:

```javascript
const express = require('express');
const crypto = require('crypto');
const { GraphQLClient } = require('graphql-request');

const app = express();
const graphqlClient = new GraphQLClient('https://api.durohub.com/graphql', {
  headers: {
    'x-api-key': process.env.DURO_API_KEY,
  },
});

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(rawBody, headerValue) {
  if (!headerValue) return false;

  const [algorithm, providedHex] = headerValue.split('=', 2);
  if (algorithm !== 'sha256' || !providedHex) return false;

  const expectedHex = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  const provided = Buffer.from(providedHex, 'hex');
  const expected = Buffer.from(expectedHex, 'hex');
  if (provided.length !== expected.length) return false;

  return crypto.timingSafeEqual(provided, expected);
}

// Use raw body for signature verification
app.post('/webhooks/duro', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = req.body; // Buffer

  // Verify signature against the raw bytes Duro signed.
  if (!verifySignature(rawBody, signature)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Unauthorized');
  }

  // Acknowledge receipt immediately (respond within timeout)
  res.status(200).send('OK');

  // Process asynchronously
  const payload = JSON.parse(rawBody.toString('utf8'));
  await processWebhook(payload);
});

async function processWebhook(payload) {
  const { event, eventId, metadata } = payload;

  console.log(`Processing ${event} event (${eventId})`);

  switch (event) {
    // Component events
    case 'components.created':
    case 'components.updated':
      await handleComponentChange(metadata);
      break;
    case 'components.deleted':
      await handleComponentDeleted(metadata);
      break;

    // Change order events
    case 'change_orders.opened':
      await handleChangeOrderOpened(metadata);
      break;
    case 'change_orders.updated':
      await handleChangeOrderUpdated(metadata);
      break;
    case 'change_orders.deleted':
      await handleChangeOrderDeleted(metadata);
      break;
    case 'change_orders.stage_transition':
      await handleStageTransition(metadata);
      break;
    case 'change_orders.stage_reviewer_decision':
      await handleReviewerDecision(metadata);
      break;
    case 'change_orders.resolution':
      await handleChangeOrderResolution(metadata);
      break;

    default:
      console.log(`Unhandled event type: ${event}`);
  }
}

async function handleComponentChange(metadata) {
  const { componentId, revisionValue, version } = metadata;

  // Fetch full component details from Duro
  const query = `
    query GetComponent($id: ID!) {
      components {
        get(filter: { ids: [$id] }) {
          connection {
            edges {
              node {
                id
                cpn
                name
                description
                revision
                status
              }
            }
          }
        }
      }
    }
  `;

  const data = await graphqlClient.request(query, { id: componentId });
  const component = data.components.get.connection.edges[0]?.node;

  if (component) {
    console.log(`Component ${component.cpn} (${component.name}) was updated`);
    // Sync to your ERP, database, or other system
    await syncToExternalSystem(component);
  }
}

async function handleComponentDeleted(metadata) {
  const { componentId } = metadata;
  console.log(`Component ${componentId} was deleted`);
  // Handle deletion in your external systems
  await removeFromExternalSystem(componentId);
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});
```

***

## Retry Logic & Error Handling

Duro automatically retries failed webhook deliveries using exponential backoff.

### Retry Schedule

When a webhook delivery fails, Duro will retry with the following delays:

| Attempt    | Delay After Failure     |
| ---------- | ----------------------- |
| 1st retry  | 1 second                |
| 2nd retry  | 2 seconds               |
| 3rd retry  | 4 seconds               |
| 4th retry  | 8 seconds               |
| 5th retry  | 16 seconds              |
| 6th retry  | 32 seconds              |
| 7th retry  | 64 seconds              |
| 8th retry  | 128 seconds             |
| 9th retry  | 256 seconds             |
| 10th retry | 300 seconds (5 min max) |

The retry schedule follows exponential backoff (2x multiplier) with a maximum delay of 5 minutes.

### What Counts as Success?

* **Success**: HTTP status codes 200-299
* **Failure**: All other status codes, timeouts, or connection errors

### Your Endpoint Should

1. **Respond quickly** - Return a 200 status code immediately, then process asynchronously
2. **Handle duplicates** - Use `eventId` for idempotency; you may receive the same event more than once
3. **Log failures** - Track and investigate webhook processing failures
4. **Be available** - Ensure your endpoint is highly available to receive webhooks

### Example: Idempotent Processing

```javascript
const processedEvents = new Set(); // In production, use Redis or a database

async function processWebhook(payload) {
  const { eventId, event, metadata } = payload;

  // Check if we've already processed this event
  if (processedEvents.has(eventId)) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }

  // Process the event
  await handleEvent(event, metadata);

  // Mark as processed
  processedEvents.add(eventId);
}
```

***

## Monitoring Webhook Activity

Query your webhook logs to monitor delivery status and troubleshoot issues:

```graphql
query GetWebhookLogs {
  webhooks {
    getLogs(webhookId: "webhook-uuid") {
      id
      event
      payload
      status
      responseCode
      responseTimeMs
      errorMessage
      attemptCount
      nextRetryAt
      isAcknowledged
      createdAt
    }
  }
}
```

### Log Status Values

| Status      | Description                           |
| ----------- | ------------------------------------- |
| `PENDING`   | Delivery in progress                  |
| `DELIVERED` | Successfully delivered (2xx response) |
| `RETRYING`  | Failed, waiting for retry             |
| `FAILED`    | All retry attempts exhausted          |

### Example: Monitoring Script

```javascript
async function checkWebhookHealth(webhookId) {
  const query = `
    query GetLogs($webhookId: String!) {
      webhooks {
        getLogs(webhookId: $webhookId) {
          id
          event
          status
          responseCode
          attemptCount
          errorMessage
          createdAt
        }
      }
    }
  `;

  const data = await graphqlClient.request(query, { webhookId });
  const logs = data.webhooks.getLogs;

  const failed = logs.filter(log => log.status === 'FAILED');
  const retrying = logs.filter(log => log.status === 'RETRYING');

  if (failed.length > 0) {
    console.warn(`${failed.length} webhook deliveries failed!`);
    failed.forEach(log => {
      console.warn(`  - ${log.event}: ${log.errorMessage}`);
    });
  }

  if (retrying.length > 0) {
    console.log(`${retrying.length} webhooks pending retry`);
  }
}
```

***

## Common Use Cases

### ERP Integration

Sync component and BOM changes to your ERP system in real-time:

```javascript
async function syncToERP(component) {
  // Map Duro fields to your ERP schema
  const erpItem = {
    partNumber: component.cpn,
    description: component.name,
    revision: component.revision,
    status: mapStatusToERP(component.status),
  };

  // Upsert to ERP
  await erpClient.upsertItem(erpItem);
  console.log(`Synced ${component.cpn} to ERP`);
}

app.post('/webhooks/duro', async (req, res) => {
  // ... verification code ...

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

  const { event, metadata } = req.body;

  if (event === 'components.created' || event === 'components.updated') {
    const component = await fetchComponentFromDuro(metadata.componentId);
    await syncToERP(component);
  }
});
```

### Slack Notifications

Alert your team about important component changes:

```javascript
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_TOKEN);

async function notifySlack(event, component) {
  const emoji = event === 'components.created' ? ':heavy_plus_sign:' : ':pencil2:';
  const action = event === 'components.created' ? 'created' : 'updated';

  await slack.chat.postMessage({
    channel: '#engineering-updates',
    text: `${emoji} Component *${component.cpn}* was ${action}`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${emoji} Component *${component.cpn}* was ${action}`,
        },
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Name:*\n${component.name}` },
          { type: 'mrkdwn', text: `*Revision:*\n${component.revision}` },
          { type: 'mrkdwn', text: `*Status:*\n${component.status}` },
        ],
      },
    ],
  });
}
```

### Audit Logging

Maintain a detailed audit trail of all changes:

```javascript
async function logToAuditSystem(payload, component) {
  const auditEntry = {
    timestamp: payload.timestamp,
    eventId: payload.eventId,
    eventType: payload.event,
    resourceType: 'component',
    resourceId: payload.metadata.componentId,
    resourceCpn: component?.cpn,
    revision: payload.metadata.revisionValue,
    version: payload.metadata.version,
  };

  await auditDatabase.insert('webhook_audit_log', auditEntry);
  console.log(`Audit log created for ${payload.eventId}`);
}
```

### Change Order Workflow Tracking

Monitor change order progress and sync approval status to external systems:

```javascript
async function handleChangeOrderEvents(payload) {
  const { event, metadata, timestamp } = payload;

  switch (event) {
    case 'change_orders.opened':
      // Change order submitted for review
      await notifyReviewers(metadata.changeOrderId);
      await updateProjectManagementSystem(metadata.changeOrderId, 'in_review');
      break;

    case 'change_orders.stage_transition':
      // Track workflow progress
      console.log(`CO ${metadata.changeOrderId} moved to stage ${metadata.newStageId}`);
      await syncWorkflowStatus(metadata);
      break;

    case 'change_orders.stage_reviewer_decision':
      // Individual reviewer decision
      const action = metadata.decision === 'approved' ? 'approved' : 'rejected';
      await logReviewerAction(metadata.changeOrderId, metadata.reviewerId, action);
      break;

    case 'change_orders.resolution':
      // Final resolution - approved, rejected, or withdrawn
      if (metadata.resolution === 'approved') {
        await triggerPostApprovalWorkflow(metadata.changeOrderId);
        await notifyStakeholders(metadata.changeOrderId, 'Change order approved');
      } else if (metadata.resolution === 'rejected') {
        await notifyOwner(metadata.changeOrderId, 'Change order rejected');
      }
      break;
  }
}

async function triggerPostApprovalWorkflow(changeOrderId) {
  // Fetch full change order details
  const changeOrder = await fetchChangeOrderFromDuro(changeOrderId);

  // Sync approved components to ERP
  for (const item of changeOrder.items) {
    if (item.action === 'release') {
      await syncComponentToERP(item.component);
    }
  }

  console.log(`Post-approval workflow completed for CO ${changeOrderId}`);
}
```

***

## Troubleshooting

### Common Issues

#### Webhook not receiving events

1. **Check if webhook is enabled** - Query the webhook and verify `isEnabled: true`
2. **Verify event subscriptions** - Ensure the correct events are in the `events` array
3. **Check your endpoint** - Verify your URL is accessible from the internet
4. **Review logs** - Use the `getLogs` query to see delivery attempts and errors

#### Signature verification failing

1. **Check the secret** - Ensure you're using the exact same `signingSecret` you configured on the webhook
2. **Use the raw body** - Signature is computed over the raw request bytes, not a re-serialized JSON object. Any middleware that parses or rewrites the body before verification will break the signature
3. **Strip the `sha256=` prefix** - The header value is `sha256=<hex>`, not a bare hex digest. Split on the first `=` and compare only the hex portion
4. **Match digest lengths first** - `crypto.timingSafeEqual` throws when buffers differ in length; guard with a length check before comparing
5. **Check encoding** - Ensure UTF-8 encoding throughout

#### Missing webhook deliveries

1. **Check retry status** - Some deliveries may be queued for retry
2. **Verify library scope** - Webhooks only fire for events in their configured library
3. **Check component filters** - Events fire for all components in the library

### Testing Your Webhook Endpoint

Before configuring a production webhook, test your endpoint:

```bash
# Send a test payload to your endpoint
curl -X POST https://your-endpoint.com/webhooks/duro \
  -H "Content-Type: application/json" \
  -H "User-Agent: Duro-Webhook-Service/1.0" \
  -d '{
    "event": "components.updated",
    "eventId": "test-event-id",
    "sourceId": "test-source-id",
    "timestamp": "2025-01-15T10:30:00.000Z",
    "metadata": {
      "componentId": "test-component-id",
      "revisionValue": "1.A",
      "version": 1
    }
  }'
```

***

## Migrating from v1 to v2

v1 webhook payloads included complete resource data. v2 uses a "Ping then Pull" pattern—you receive minimal metadata and fetch full details via GraphQL when needed.

### Event Mapping

| v1 Event       | v2 Event                  | Notes                                      |
| -------------- | ------------------------- | ------------------------------------------ |
| `co.Submitted` | `CHANGE_ORDER_OPENED`     |                                            |
| `co.Approved`  | `CHANGE_ORDER_RESOLUTION` | Check `metadata.resolution === 'approved'` |
| `co.Rejected`  | `CHANGE_ORDER_RESOLUTION` | Check `metadata.resolution === 'rejected'` |

### Example: Change Order Approved

**v1 payload** (complete data embedded):

```json
{
  "_id": "507f1f77bcf86cd799439011",
  "con": "ECO-001",
  "name": "Update PCB Design",
  "description": "Replace capacitors with higher rated components",
  "eventType": "co.Approved",
  "resolution": "APPROVED",
  "status": "CLOSED",
  "type": "ECO",
  "created": "2025-01-15T10:00:00.000Z",
  "creator": "507f1f77bcf86cd799439012",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john@example.com",
  "approvalType": "STANDARD",
  "lastModified": "2025-01-15T14:00:00.000Z",
  "approverList": [
    {
      "user": "507f1f77bcf86cd799439013",
      "firstName": "Jane",
      "lastName": "Smith",
      "email": "jane@example.com",
      "action": "APPROVED",
      "performedAt": "2025-01-15T14:00:00.000Z"
    }
  ],
  "children": {
    "components": [
      { "_id": "507f1f77bcf86cd799439014", "cpn": "CMP-001234", "name": "Capacitor 100uF" }
    ],
    "products": []
  },
  "history": [
    { "action": "APPROVED", "user": "507f1f77bcf86cd799439013", "created": "2025-01-15T14:00:00.000Z" }
  ]
}
```

**v2 payload** (metadata only):

```json
{
  "event": "change_orders.resolution",
  "eventId": "b8c9d0e1-f2a3-4567-1234-890123456789",
  "sourceId": "nats-msg-66666",
  "timestamp": "2025-01-15T14:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "resolution": "approved"
  }
}
```

### What's Not Included in v2

The following v1 fields are **not** in the v2 webhook payload. Use the [GraphQL API](#fetch-change-order-details) to retrieve this data:

| v1 Field                                    | How to Get in v2                                         |
| ------------------------------------------- | -------------------------------------------------------- |
| `con`, `name`, `description`                | Query `changeOrders.get()`                               |
| `status`, `type`, `resolution`              | Query `changeOrders.get()`                               |
| `creator`, `firstName`, `lastName`, `email` | Query `changeOrders.get()` with `createdBy` field        |
| `approverList`                              | Query `changeOrders.get()` with `reviewers` field        |
| `children.components`, `children.products`  | Query `changeOrders.get()` with `items` field            |
| `history`                                   | Query change order audit log                             |
| `created`, `lastModified`                   | Query `changeOrders.get()` with `createdAt`, `updatedAt` |

### Updating Your Handler

**v1:**

```javascript
if (payload.eventType === 'co.Approved') {
  for (const component of payload.children.components) {
    releaseToERP(component.cpn);
  }
}
```

**v2:**

```javascript
if (payload.event === 'change_orders.resolution' && payload.metadata.resolution === 'approved') {
  const changeOrder = await fetchChangeOrder(payload.metadata.changeOrderId);
  for (const item of changeOrder.items) {
    releaseToERP(item.component.cpn);
  }
}
```

{% hint style="info" %}
See [Fetch Change Order Details](#fetch-change-order-details) for the GraphQL query and [Signing Secrets](#signing-secrets) for signature verification.
{% endhint %}

***

## Next Steps

* Review [Authentication](/getting-started/authentication.md) for securing your API requests
* Explore [Searching and Filtering](/advanced-topics/searching-and-filtering.md) for advanced component queries
* Learn about [Change Orders](/core-concepts/change-orders.md) to understand change order workflows
* Check out [Error Handling](/advanced-topics/error-handling.md) for robust integration patterns


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.durohub.com/advanced-topics/webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
