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

# Webhooks

> Reference for the WebhooksService class in the Tybrite SDK.

The `WebhooksService` class (accessed via `client.webhooks`) manages outbound webhook endpoints and provides access to the event delivery log.

<Warning>
  **Secret key required.** All webhook management endpoints require a secret key (`tybrite_sk_*`). Publishable keys return `403 Forbidden`.
</Warning>

## Overview

Webhooks allow your systems to receive real-time push notifications when events occur in your store — eliminating the need to poll the API. Every delivery is HMAC-signed so you can verify authenticity before acting on the payload.

**Supported event categories:** Orders, Payments, Customers, Inventory, Cart, Gift Cards, Promotions, Catalog Sync.

**Delivery contract:**

* **At-least-once** — your endpoint must be idempotent. Use `event.id` for deduplication.
* **HMAC-signed** — verify with the `signing_secret` returned at endpoint creation.
* **Best-effort ordering** — use `event.created_at` for sequencing, not arrival order.
* **Automatic retries** — failed deliveries are retried with exponential backoff.
* **Auto-disable** — an endpoint that fails 20 deliveries in a row (each after its retries are exhausted) is automatically disabled. Re-enable it once it's healthy; see [Endpoint health & auto-disable](#endpoint-health--auto-disable).

***

## Signature Verification

Every webhook delivery includes the header:

```
X-Tybrite-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
```

Verify it in your endpoint handler:

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

function verifyTybriteWebhook(
  rawBody: string,
  signature: string,
  signingSecret: string
): boolean {
  const [tPart, v1Part] = signature.split(',');
  const timestamp = tPart.replace('t=', '');
  const receivedSig = v1Part.replace('v1=', '');

  // Reject replays older than 5 minutes
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;

  const payload = `${timestamp}.${rawBody}`;
  const expected = createHmac('sha256', signingSecret).update(payload).digest('hex');

  return timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expected));
}

// In an Express handler:
app.post('/webhooks/tybrite', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-tybrite-signature'] as string;
  if (!verifyTybriteWebhook(req.body.toString(), sig, process.env.TYBRITE_WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  switch (event.type) {
    case 'order.paid':
      await syncToErp(event.data.object);
      break;
    case 'payment.failed':
      await notifyOps(event.data.object);
      break;
  }

  res.json({ received: true });
});
```

<Tip>
  Always read the raw request body (before JSON parsing) for signature verification. Parsers may reformat the JSON, producing a different byte sequence and causing verification to fail.
</Tip>

***

## Endpoint Management

### `createWebhookEndpoint` 🔒

Register a new HTTPS endpoint to receive event notifications.

```typescript theme={null}
const { webhook_endpoint } = await client.webhooks.createWebhookEndpoint({
  requestBody: {
    url: 'https://yourapp.com/webhooks/tybrite',
    events: ['order.paid', 'order.fulfilled', 'payment.succeeded', 'payment.failed'],
    enabled: true,
    metadata: { label: 'production-erp', team: 'platform' }
  }
});

// signing_secret is returned ONCE — store it immediately
const signingSecret = webhook_endpoint.signing_secret;
console.log('Store this secret:', signingSecret);
```

```json Response theme={null}
{
  "webhook_endpoint": {
    "id": "ca8e7b1e-86e4-46a3-a716-eab9e5ffc168",
    "store_id": "store-uuid",
    "url": "https://yourapp.com/webhooks/tybrite",
    "events": ["order.paid", "order.fulfilled", "payment.succeeded", "payment.failed"],
    "enabled": true,
    "metadata": { "label": "production-erp", "team": "platform" },
    "created_at": "2026-06-20T11:30:48.673+00:00",
    "updated_at": "2026-06-20T11:30:48.673+00:00",
    "signing_secret": "6c08d77c…09103824"
  }
}
```

<Warning>
  The `signing_secret` is shown **once** at creation. It cannot be retrieved again. Store it in your secrets manager (e.g. AWS Secrets Manager, Doppler, Infisical) immediately.
</Warning>

To subscribe to **all events**, pass `["*"]` as the events array:

```typescript theme={null}
await client.webhooks.createWebhookEndpoint({
  requestBody: {
    url: 'https://yourapp.com/webhooks/tybrite',
    events: ['*'], // receive everything
  }
});
// → { webhook_endpoint: { id, url, events: ['*'], enabled: true, signing_secret, … } }
```

**Supported event types:**

| Category              | Events                                                                                                                                                                                            |
| :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Orders                | `order.created`, `order.paid`, `order.fulfilled`, `order.shipped`, `order.cancelled`, `order.refunded`, `order.updated`                                                                           |
| Payments              | `payment.succeeded`, `payment.failed`, `payment.refunded`                                                                                                                                         |
| Customers             | `customer.created`, `customer.updated`, `customer.deleted`                                                                                                                                        |
| Inventory             | `product.created`, `product.updated`, `product.stock_low`, `product.out_of_stock`                                                                                                                 |
| Cart                  | `cart.created`, `cart.updated`, `cart.abandoned`                                                                                                                                                  |
| Gift Cards            | `gift_card.issued`, `gift_card.redeemed`, `gift_card.expired`                                                                                                                                     |
| Promotions            | `promotion.applied`, `promotion.created`, `promotion.activated`, `promotion.deactivated`                                                                                                          |
| Content & collections | `collection.created`, `collection.updated`, `post.published`, `lookbook.published`, `review.approved`                                                                                             |
| Store lifecycle       | `store.updated`, `payment_provider.connected`, `shipping_provider.connected`, `channel.connected`                                                                                                 |
| Feature availability  | `feature.status_changed` (a capability crossed between `available` / `awaiting_data` / `not_in_plan` — carries `feature`, `status`, `previous_status`)                                            |
| Catalog Sync          | `feed.sync.completed` (a scheduled inbound feed-pull finished — carries created/updated/failed counts), `channel.sync.completed` (a sales-channel push finished — carries pushed/rejected counts) |
| Wholesale (B2B)       | `b2b.rfq.created`, `b2b.quote.sent` / `.accepted` / `.rejected`, `b2b.po.issued` / `.confirmed` / `.fulfilled`, `b2b.invoice.issued` / `.paid` / `.overdue` (stores with wholesale enabled)       |

The store-lifecycle, content, and feature-availability events describe a store *growing* rather than a
transaction — the signal an automation tool uses to keep a storefront in step with the store (a promo
banner the moment a promotion goes live, a reviews block when reviews are first approved, a feature
surface the moment its data exists). See the [webhooks guide](/webhooks) for the full catalogue and the
[automated workflows](/workflows/webhooks-automation) guide for the pattern.

***

### `listWebhookEndpoints` 🔒

```typescript theme={null}
const { webhook_endpoints, pagination } = await client.webhooks.listWebhookEndpoints({
  limit: 20,
});
```

```json Response theme={null}
{
  "webhook_endpoints": [
    {
      "id": "ca8e7b1e-86e4-46a3-a716-eab9e5ffc168",
      "store_id": "store-uuid",
      "url": "https://yourapp.com/webhooks/tybrite",
      "events": ["order.created", "order.updated", "customer.created"],
      "enabled": true,
      "metadata": {},
      "last_success_at": null,
      "last_failure_at": null,
      "consecutive_failures": 0,
      "disabled_reason": null,
      "disabled_at": null,
      "created_at": "2026-06-20T11:30:48.673+00:00",
      "updated_at": "2026-06-20T11:30:48.673+00:00"
    }
  ],
  "pagination": { "limit": 20, "next_cursor": null, "has_more": false }
}
```

<Note>The `signing_secret` is **not** included on list or get — it's returned only once, at creation.</Note>

***

### `getWebhookEndpoint` 🔒

Returns full endpoint details including delivery statistics.

```typescript theme={null}
const { webhook_endpoint } = await client.webhooks.getWebhookEndpoint({
  id: 'endpoint-uuid',
});

console.log(webhook_endpoint.delivery_stats);
// { total: 1420, successful: 1418, failed: 2 }

console.log(webhook_endpoint.consecutive_failures); // 0 when healthy
console.log(webhook_endpoint.disabled_reason);      // null unless auto-disabled
```

***

### Endpoint health & auto-disable

Every endpoint tracks its recent delivery health:

| Field                  | Meaning                                                                                                       |
| :--------------------- | :------------------------------------------------------------------------------------------------------------ |
| `last_success_at`      | When the endpoint last accepted a delivery (2xx).                                                             |
| `last_failure_at`      | When a delivery to the endpoint last failed.                                                                  |
| `consecutive_failures` | Deliveries that have failed in a row after exhausting their retries. Reset to `0` by any successful delivery. |
| `disabled_reason`      | Set when the endpoint was **auto-disabled**; `null` otherwise.                                                |
| `disabled_at`          | When it was auto-disabled; `null` otherwise.                                                                  |

When `consecutive_failures` reaches **20**, the endpoint is automatically disabled (`enabled: false`) so a permanently-dead endpoint stops accumulating deliveries. Fix the endpoint, then re-enable it — re-enabling resets the failure counter and clears `disabled_reason`:

```typescript theme={null}
await client.webhooks.updateWebhookEndpoint({
  id: 'endpoint-uuid',
  requestBody: { enabled: true } // resets consecutive_failures, clears disabled_reason
});
// → { webhook_endpoint: { …, enabled: true, consecutive_failures: 0, disabled_reason: null, disabled_at: null } }
```

***

### `updateWebhookEndpoint` 🔒

All fields are optional. Only provided fields are updated.

```typescript theme={null}
// Disable an endpoint temporarily
await client.webhooks.updateWebhookEndpoint({
  id: 'endpoint-uuid',
  requestBody: { enabled: false }
});
// → { webhook_endpoint: { …, enabled: false } }

// Change subscribed events
const { webhook_endpoint } = await client.webhooks.updateWebhookEndpoint({
  id: 'endpoint-uuid',
  requestBody: {
    events: ['order.paid', 'order.cancelled', 'payment.failed']
  }
});
```

The updated endpoint is returned (without `signing_secret`):

```json Response theme={null}
{
  "webhook_endpoint": {
    "id": "ca8e7b1e-86e4-46a3-a716-eab9e5ffc168",
    "store_id": "store-uuid",
    "url": "https://yourapp.com/webhooks/tybrite",
    "events": ["order.paid", "order.cancelled", "payment.failed"],
    "enabled": true,
    "metadata": {},
    "last_success_at": null,
    "last_failure_at": null,
    "consecutive_failures": 0,
    "disabled_reason": null,
    "disabled_at": null,
    "created_at": "2026-06-20T11:30:48.673+00:00",
    "updated_at": "2026-06-20T11:30:51.468343+00:00"
  }
}
```

***

### `deleteWebhookEndpoint` 🔒

Soft-deletes the endpoint. In-flight deliveries are not cancelled.

```typescript theme={null}
await client.webhooks.deleteWebhookEndpoint({ id: 'endpoint-uuid' });
// → { success: true, message: 'Webhook endpoint deleted' }
```

***

### `sendTestWebhookEvent` 🔒

Sends a synthetic test payload to an endpoint. Use this to verify your signature verification code and endpoint connectivity before going live.

The payload carries `"test": true` (it's synthetic — `data.object` holds placeholder values, so your handler should skip side effects) and a `livemode` flag reflecting the key's environment. These are two distinct signals — see [`livemode` vs `test`](#livemode-vs-test--two-different-flags).

```typescript theme={null}
const result = await client.webhooks.sendTestWebhookEvent({
  id: 'endpoint-uuid',
  requestBody: { event_type: 'order.paid' }
});

console.log(result.success);     // true
console.log(result.status_code); // 200
```

```json Response theme={null}
{
  "success": true,
  "event_id": "evt_test_1781955051835_uuib0s8",
  "event_type": "order.created",
  "endpoint_url": "https://yourapp.com/webhooks/tybrite",
  "status_code": 200,
  "response_body": "{\"received\":true}"
}
```

Available test event types: `order.created`, `order.paid`, `order.fulfilled`, `order.shipped`, `order.cancelled`, `payment.succeeded`, `payment.failed`, `customer.created`, `product.created`, `product.stock_low`, `cart.abandoned`, `gift_card.issued`, `promotion.applied`, `promotion.activated`, `collection.created`, `post.published`, `review.approved`, `store.updated`, `payment_provider.connected`, `feature.status_changed`, `feed.sync.completed`, `channel.sync.completed`.

***

## Event Log

### `listWebhookEvents` 🔒

Returns the event log in reverse-chronological order. Useful for debugging delivery failures or auditing event history.

```typescript theme={null}
// All events
const { webhook_events } = await client.webhooks.listWebhookEvents({ limit: 50 });

// Filter by type
const { webhook_events: failedPayments } = await client.webhooks.listWebhookEvents({
  type: 'payment.failed',
  limit: 20,
});

// Filter by environment (production vs sandbox/test)
const { webhook_events: liveEvents } = await client.webhooks.listWebhookEvents({
  environment: 'production',
  limit: 50,
});
```

Each call returns the same shape — a `webhook_events` array plus a `pagination` cursor:

```json Response theme={null}
{
  "webhook_events": [
    {
      "id": "evt_1781955476025_lp1jafe",
      "type": "order.created",
      "environment": "production",
      "payload": {
        "id": "evt_1781955476025_lp1jafe",
        "type": "order.created",
        "api_version": "v1",
        "livemode": true,
        "created_at": "2026-06-20T11:37:56.025Z",
        "data": {
          "object": {
            "id": "e7d28e96-e9e0-45ce-be28-51812f63eb4b",
            "order_number": "ORD-1781955473455",
            "order_status": "pending",
            "payment_status": "paid",
            "total_amount": 603.99
          }
        }
      },
      "created_at": "2026-06-20T11:37:56.025+00:00"
    }
  ],
  "pagination": {
    "limit": 50,
    "next_cursor": "MjAyNi0wNi0yMFQxMTozNzo1Mi4wMjYrMDA6MDA=",
    "has_more": true
  }
}
```

Each event also carries an `environment` field (`production` or `sandbox`) so you can tell live and test events apart in the log.

***

### `getWebhookEvent` 🔒

Returns a single event with all its delivery attempts across all endpoints.

```typescript theme={null}
const { webhook_event } = await client.webhooks.getWebhookEvent({
  id: 'evt_1716199800000_abc123'
});

// Inspect delivery attempts
for (const delivery of webhook_event.deliveries) {
  console.log(delivery.attempt_number, delivery.success, delivery.status_code, delivery.latency_ms);
}
```

```json Response theme={null}
{
  "webhook_event": {
    "id": "evt_1781955476025_lp1jafe",
    "store_id": "store-uuid",
    "type": "order.created",
    "environment": "production",
    "payload": {
      "id": "evt_1781955476025_lp1jafe",
      "type": "order.created",
      "api_version": "v1",
      "livemode": true,
      "created_at": "2026-06-20T11:37:56.025Z",
      "data": { "object": { "id": "e7d28e96-e9e0-45ce-be28-51812f63eb4b", "order_number": "ORD-1781955473455", "payment_status": "paid", "total_amount": 603.99 } }
    },
    "created_at": "2026-06-20T11:37:56.025+00:00",
    "deliveries": [
      {
        "id": "d0d430c6-9544-4009-ad55-285b49678a9e",
        "endpoint_id": "58da9a58-6ac3-49ce-9150-0350519141b1",
        "attempt_number": 1,
        "status_code": 200,
        "response_body": "{\"received\":true}",
        "latency_ms": 275,
        "success": true,
        "attempted_at": "2026-06-20T11:37:56.496+00:00"
      }
    ]
  }
}
```

***

### `retryWebhookEvent` 🔒

Manually re-delivers the event to all enabled endpoints subscribed to its event type. Each re-delivery is recorded as a new attempt.

```typescript theme={null}
const result = await client.webhooks.retryWebhookEvent({
  id: 'evt_1716199800000_abc123'
});

console.log(`Retried to ${result.retried} endpoint(s)`);
for (const r of result.results) {
  console.log(r.endpoint_id, r.success, r.status_code);
}
```

```json Response theme={null}
{
  "event_id": "evt_1781955476025_lp1jafe",
  "retried": 2,
  "results": [
    { "endpoint_id": "2b755d69-7627-40b3-b0b6-4458e23d64bc", "success": true, "status_code": 200 },
    { "endpoint_id": "58da9a58-6ac3-49ce-9150-0350519141b1", "success": true, "status_code": 200 }
  ]
}
```

***

## Event Payload Shape

All events use a consistent envelope:

```json theme={null}
{
  "id": "evt_1716199800000_abc123",
  "type": "order.paid",
  "created_at": "2026-05-20T10:30:00Z",
  "store_id": "store-uuid",
  "api_version": "v1",
  "livemode": true,
  "data": {
    "object": { /* the resource — order, payment, customer, etc. */ }
  },
  "previous_attributes": {
    /* on update events: prior values for changed fields only */
  }
}
```

### `livemode` vs `test` — two different flags

The envelope carries two boolean signals that are easy to confuse but answer **different** questions. Check the one that matches what you're deciding:

| Flag       | Question it answers                      | When it's set                                                                                                                                              |
| :--------- | :--------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `livemode` | *Which environment produced this event?* | **Always present.** `true` for production; `false` for any event emitted while authenticating with a test key (`tybrite_sk_test_…` / `tybrite_pk_test_…`). |
| `test`     | *Is this payload synthetic/fake?*        | **Only** on events sent via `sendTestWebhookEvent` (the "send test" endpoint). Its `data.object` holds placeholder values. Absent on all real events.      |

* Use **`livemode`** to **route** — e.g. drop anything where `livemode === false` in your production handler.
* Use **`test`** to **skip side effects** — don't fulfil, charge, or persist when `test === true`, because the data is fake.

They're orthogonal. A **real** order placed with a test key is `livemode: false` with **no** `test` flag — a genuine sandbox event you can process as an end-to-end integration test. A synthetic test fired while on a live key is `livemode: true` with `test: true`.

```typescript theme={null}
const event = JSON.parse(req.body.toString());

if (event.test) return res.json({ received: true });      // synthetic — acknowledge, do nothing
if (!event.livemode) return res.json({ received: true }); // sandbox — ignore in production handler

await processEvent(event); // real, production event
```

Use `event.id` for idempotency — store it and skip processing if you've seen it before:

```typescript theme={null}
const event = JSON.parse(req.body.toString());

// Idempotency check
if (await db.webhookEvents.exists(event.id)) {
  return res.json({ received: true }); // already processed
}

await db.webhookEvents.markSeen(event.id);
await processEvent(event);
```

***

## Response Codes

| Code  | Meaning                                                                                          |
| :---- | :----------------------------------------------------------------------------------------------- |
| `200` | Success on reads, updates, deletes, retry, and test delivery.                                    |
| `201` | New endpoint created.                                                                            |
| `400` | Invalid URL (must be HTTPS), empty event list, invalid event type, or disabled endpoint on test. |
| `401` | Missing or invalid API key.                                                                      |
| `403` | Publishable key used — all webhook operations require a secret key.                              |
| `404` | Endpoint or event not found.                                                                     |
| `429` | Rate limit exceeded (500 req/hr per secret key).                                                 |
| `500` | Internal server error.                                                                           |
