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

# Bkmark Webhooks — Real-Time Event Notifications API

> Receive HTTP POST notifications when Bkmark events occur. Covers subscription setup, payload formats, HMAC signature verification, retries, and polling.

Webhooks let your application react to changes in a user's Bkmark account the moment they happen — no polling required. When an event occurs (a bookmark is created, a tag is renamed, a group is deleted), Bkmark sends an HTTP `POST` request containing a JSON payload to the URL you registered. You verify the request came from Bkmark using an HMAC-SHA256 signature, then process the event however your application needs.

## Creating a Subscription

Send a `POST` request to `/api/v1/webhooks` with the endpoint URL you want to receive events and the list of events you want to subscribe to.

```bash theme={null}
curl -X POST https://api.bkmark.it/api/v1/webhooks \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/bkmark",
    "events": ["bookmark.created", "bookmark.deleted", "tag.created"]
  }'
```

<Warning>
  The `secret` field is returned **only once** — at creation time. Store it immediately in a secure secrets manager (such as AWS Secrets Manager, HashiCorp Vault, or an environment variable). You cannot retrieve it again. If you lose it, delete the subscription and create a new one.
</Warning>

**Response `201 Created`:**

```json theme={null}
{
  "id": "wh_01hq8p2z3kxv7m4n9rbt5c6d",
  "url": "https://yourapp.com/webhooks/bkmark",
  "secret": "whsec_a3f8c2d1e94b76052718af3c0d9e1b47",
  "events": ["bookmark.created", "bookmark.deleted", "tag.created"],
  "active": true,
  "createdAt": "2025-06-01T09:00:00.000Z"
}
```

<ResponseField name="id" type="string">
  Unique identifier for this webhook subscription. Use it to update, delete, or query deliveries.
</ResponseField>

<ResponseField name="url" type="string">
  The HTTPS endpoint Bkmark will POST events to.
</ResponseField>

<ResponseField name="secret" type="string">
  HMAC-SHA256 signing secret. **Shown once only.** Use this to verify the `X-Bkmark-Signature` header on every incoming request.
</ResponseField>

<ResponseField name="events" type="array">
  The event types this subscription will receive.
</ResponseField>

<ResponseField name="active" type="boolean">
  Whether the subscription is currently enabled.
</ResponseField>

### Request Body Parameters

<ParamField body="url" type="string" required>
  The HTTPS endpoint Bkmark will deliver events to. Must be publicly accessible.
</ParamField>

<ParamField body="events" type="array of strings" required>
  One or more event type strings. Subscribe to all events with `["*"]`, or list individual events. See [Available Events](#available-events) below.
</ParamField>

***

## Available Events

Bkmark emits events across three resource types: bookmarks, groups, and tags.

| Event                  | Trigger                                                       |
| ---------------------- | ------------------------------------------------------------- |
| `bookmark.created`     | A new bookmark is saved                                       |
| `bookmark.updated`     | A bookmark's title, description, or other fields change       |
| `bookmark.deleted`     | A bookmark is permanently deleted                             |
| `bookmark.favorited`   | A bookmark is marked as a favorite (**transition only**)      |
| `bookmark.unfavorited` | A bookmark's favorite status is removed (**transition only**) |
| `bookmark.archived`    | A bookmark is archived (**transition only**)                  |
| `bookmark.unarchived`  | A bookmark is unarchived (**transition only**)                |
| `bookmark.restored`    | A soft-deleted bookmark is restored from trash                |
| `group.created`        | A new group is created                                        |
| `group.updated`        | A group's name or color changes                               |
| `group.deleted`        | A group is deleted                                            |
| `tag.created`          | A new tag is created                                          |
| `tag.updated`          | A tag's name or color changes                                 |
| `tag.deleted`          | A tag is deleted                                              |

<Note>
  **Transition events** (`bookmark.favorited`, `bookmark.unfavorited`, `bookmark.archived`, `bookmark.unarchived`) fire only when the boolean state actually changes. For example, `PATCH`ing `isArchived: true` on a bookmark that is already archived does not fire `bookmark.archived`. No-op updates to tags and groups also do not fire `tag.updated` or `group.updated`.
</Note>

***

## Payload Format

Every webhook delivery is a `POST` request with `Content-Type: application/json`. The top-level structure is consistent across all events:

```json theme={null}
{
  "event": "bookmark.created",
  "timestamp": "2025-06-01T14:32:10.000Z",
  "data": { }
}
```

<ResponseField name="event" type="string">
  The event type string, e.g. `bookmark.created`.
</ResponseField>

<ResponseField name="timestamp" type="string (ISO 8601)">
  UTC timestamp of when the event occurred. Use this along with the entity `id` for idempotency checks.
</ResponseField>

<ResponseField name="data" type="object">
  The event payload. Shape varies by event type — see the examples below.
</ResponseField>

### Payload Examples

<Expandable title="bookmark.created">
  ```json theme={null}
  {
    "event": "bookmark.created",
    "timestamp": "2025-06-01T14:32:10.000Z",
    "data": {
      "bookmark": {
        "id": "bk_01hq8p2z3kxv7m4n9rbt5c6d",
        "url": "https://example.com/article",
        "title": "Understanding OAuth2 PKCE",
        "description": "A practical guide to the PKCE extension for OAuth2.",
        "isFavorite": false,
        "isArchived": false,
        "tags": [
          { "id": "tag_01abc", "name": "security" }
        ],
        "groups": [
          { "id": "grp_01xyz", "name": "Reading List" }
        ],
        "createdAt": "2025-06-01T14:32:10.000Z",
        "updatedAt": "2025-06-01T14:32:10.000Z"
      }
    }
  }
  ```
</Expandable>

<Expandable title="bookmark.updated — includes a changes array">
  The `data` object includes a `changes` array listing every field that changed in this update.

  ```json theme={null}
  {
    "event": "bookmark.updated",
    "timestamp": "2025-06-02T09:15:00.000Z",
    "data": {
      "bookmark": {
        "id": "bk_01hq8p2z3kxv7m4n9rbt5c6d",
        "url": "https://example.com/article",
        "title": "Understanding OAuth2 PKCE — Updated",
        "description": "Revised and expanded.",
        "isFavorite": false,
        "isArchived": false,
        "tags": [
          { "id": "tag_01abc", "name": "security" }
        ],
        "groups": [
          { "id": "grp_01xyz", "name": "Reading List" }
        ],
        "createdAt": "2025-06-01T14:32:10.000Z",
        "updatedAt": "2025-06-02T09:15:00.000Z"
      },
      "changes": ["title", "description"]
    }
  }
  ```
</Expandable>

<Expandable title="bookmark.deleted">
  Deleted bookmark payloads include the ID, URL, and title so you can identify the resource even though it no longer exists.

  ```json theme={null}
  {
    "event": "bookmark.deleted",
    "timestamp": "2025-06-03T11:00:00.000Z",
    "data": {
      "bookmarkId": "bk_01hq8p2z3kxv7m4n9rbt5c6d",
      "url": "https://example.com/article",
      "title": "Understanding OAuth2 PKCE — Updated",
      "deletedAt": "2025-06-03T11:00:00.000Z"
    }
  }
  ```
</Expandable>

<Expandable title="bookmark.restored">
  Fires when a soft-deleted bookmark is recovered from trash.

  ```json theme={null}
  {
    "event": "bookmark.restored",
    "timestamp": "2025-06-04T08:44:00.000Z",
    "data": {
      "id": "bk_01hq8p2z3kxv7m4n9rbt5c6d",
      "url": "https://example.com/article",
      "title": "Understanding OAuth2 PKCE — Updated",
      "isArchived": false
    }
  }
  ```
</Expandable>

<Expandable title="tag.updated — includes previous object">
  `tag.updated` and `group.updated` both include a `previous` object inside `data` with the prior values of every field that changed. Useful for "if name changed from X to Y" workflows.

  ```json theme={null}
  {
    "event": "tag.updated",
    "timestamp": "2025-06-02T10:00:00.000Z",
    "data": {
      "id": "tag_01abc",
      "name": "reading-list",
      "color": "blue",
      "previous": {
        "name": "todo",
        "color": "gray"
      }
    }
  }
  ```
</Expandable>

<Expandable title="group.updated — includes previous object">
  ```json theme={null}
  {
    "event": "group.updated",
    "timestamp": "2025-06-02T10:05:00.000Z",
    "data": {
      "id": "grp_01xyz",
      "name": "Articles",
      "color": "blue",
      "previous": {
        "name": "Reading",
        "color": "gray"
      }
    }
  }
  ```
</Expandable>

<Expandable title="test event (from POST /webhooks/:id/test)">
  ```json theme={null}
  {
    "event": "test",
    "timestamp": "2025-06-01T09:00:01.000Z",
    "data": {
      "message": "This is a test webhook delivery from bkmark.it"
    }
  }
  ```
</Expandable>

***

## Signature Verification

Every delivery includes an `X-Bkmark-Signature` header containing the HMAC-SHA256 hex digest of the raw request body, computed using your webhook secret.

<Warning>
  **Always verify the signature before processing any webhook.** Skip this step and an attacker can send forged events to your endpoint. Use a timing-safe comparison function — standard string equality is vulnerable to timing attacks.
</Warning>

The verification algorithm is:

1. Read the raw request body **as bytes** (do not parse JSON first).
2. Compute `HMAC-SHA256(secret, rawBody)` and encode as a hex string.
3. Compare the result with the value in `X-Bkmark-Signature` using a timing-safe equality function.
4. Reject any request where the values do not match.

### Node.js

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();

// Use express.raw() so you get the unmodified body buffer
app.post('/webhooks/bkmark', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-bkmark-signature'];
  const secret = process.env.BKMARK_WEBHOOK_SECRET;

  if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }

  const expected = createHmac('sha256', secret)
    .update(req.body) // req.body is a Buffer when using express.raw()
    .digest('hex');

  const sigBuffer = Buffer.from(signature);
  const expBuffer = Buffer.from(expected);

  // Reject if lengths differ (timingSafeEqual requires equal-length buffers)
  if (sigBuffer.length !== expBuffer.length) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  if (!timingSafeEqual(sigBuffer, expBuffer)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Signature is valid — parse and process the event
  const { event, timestamp, data } = JSON.parse(req.body.toString());

  switch (event) {
    case 'bookmark.created':
      // e.g. sync to your own database
      console.log('New bookmark:', data.bookmark.url);
      break;
    case 'bookmark.deleted':
      console.log('Deleted bookmark ID:', data.bookmarkId);
      break;
    // Handle other events...
  }

  // Respond quickly — queue heavy work for a background job
  res.status(200).send('OK');
});
```

### Python

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

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BKMARK_WEBHOOK_SECRET"]


def verify_signature(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    # hmac.compare_digest is timing-safe
    return hmac.compare_digest(signature, expected)


@app.post("/webhooks/bkmark")
def handle_webhook():
    signature = request.headers.get("X-Bkmark-Signature", "")
    raw_body = request.get_data()  # raw bytes, before any JSON parsing

    if not verify_signature(raw_body, signature):
        abort(401, "Invalid signature")

    payload = request.get_json()
    event = payload["event"]

    if event == "bookmark.created":
        bookmark = payload["data"]["bookmark"]
        print(f"New bookmark: {bookmark['url']}")
    elif event == "bookmark.deleted":
        print(f"Deleted bookmark ID: {payload['data']['bookmarkId']}")
    # Handle other events...

    return "", 200
```

***

## Managing Subscriptions

Use the following endpoints to list, update, delete, and test your webhook subscriptions.

| Method   | Path                              | Description                                                      |
| -------- | --------------------------------- | ---------------------------------------------------------------- |
| `GET`    | `/api/v1/webhooks`                | List all subscriptions for the authenticated user                |
| `PATCH`  | `/api/v1/webhooks/:id`            | Update a subscription's URL, event list, or active status        |
| `DELETE` | `/api/v1/webhooks/:id`            | Permanently delete a subscription                                |
| `POST`   | `/api/v1/webhooks/:id/test`       | Send a test event to verify your endpoint is reachable           |
| `GET`    | `/api/v1/webhooks/:id/deliveries` | View the last 50 delivery attempts and their HTTP response codes |

**Example — disable a subscription without deleting it:**

```bash theme={null}
curl -X PATCH https://api.bkmark.it/api/v1/webhooks/wh_01hq8p2z3kxv7m4n9rbt5c6d \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'
```

***

## Delivery and Retries

Bkmark dispatches webhook requests asynchronously from a background job queue. The following guarantees and constraints apply:

* Your endpoint must return a `2xx` status code within **10 seconds**. Any non-`2xx` response or a timeout is treated as a failure.
* Failed deliveries are retried **3 times** using exponential backoff.
* You can inspect every attempt — including the HTTP response code your server returned — via `GET /api/v1/webhooks/:id/deliveries`.

<Tip>
  Respond with `200 OK` immediately and offload any heavy processing to a background worker or task queue. This prevents timeouts caused by slow database writes or third-party API calls inside your handler.
</Tip>

<Note>
  Because deliveries can be retried, your handler should be **idempotent**. Use the `timestamp` field and the entity `id` inside `data` together as a deduplication key — if you've already processed an event with that combination, skip it.
</Note>

***

## Polling Helpers

If you need to backfill events or reconcile state without webhooks, you can poll the bookmarks list endpoint with time-based filters.

| Parameter                  | Description                                                     |
| -------------------------- | --------------------------------------------------------------- |
| `?since=<ISO 8601>`        | Return bookmarks with `createdAt` **after** the given timestamp |
| `?updatedSince=<ISO 8601>` | Return bookmarks with `updatedAt` **after** the given timestamp |

Both filters are strictly greater-than — a bookmark whose timestamp exactly matches the boundary is excluded. Combine with `sortField=updatedAt&sortDir=desc` for efficient incremental sync.

```bash theme={null}
# Fetch all bookmarks created after June 1st
curl "https://api.bkmark.it/api/v1/bookmarks?since=2025-06-01T00:00:00.000Z&sortField=createdAt&sortDir=desc" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# Fetch all bookmarks updated in the last 15 minutes
curl "https://api.bkmark.it/api/v1/bookmarks?updatedSince=2025-06-01T14:15:00.000Z&sortField=updatedAt&sortDir=desc" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```
