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

# Webhook

> Send links to Claude Code, T3 Code, n8n, Make, Zapier, or any tool that receives webhooks.

A Webhook endpoint sends each delivery as a signed JSON `POST` to one URL. Any tool that receives webhooks can use it, including coding agents, automation platforms, and your own service. Several delivery channels can share one Webhook endpoint.

## How do I set up a Webhook endpoint?

1. Copy the webhook URL your receiving tool gives you.
2. Add a Webhook endpoint in Curation Space and paste the URL. The name is filled in from the URL's host; change it to anything you will recognize.
3. Add request headers only if your receiver gave you a separate token to send, such as `Authorization: Bearer …`.
4. Choose the endpoint for a delivery channel.

The endpoint is active as soon as you save it. Nothing is sent until one of its channels delivers or you send a [sample delivery](#can-i-send-a-sample-delivery).

Treat the URL like a password: many receivers accept any request sent to it. The URL and header values are stored encrypted. After saving, the URL is shown only as its host, and header values are never shown again.

Agents can create and manage Webhook endpoints through the [MCP server](https://docs.curation.space/mcp-server.md#how-does-my-agent-set-up-a-webhook-endpoint).

### What do I do in my receiving tool?

* **Claude Code:** A routine with an API trigger has a URL and a bearer token. Paste the URL, then add the request headers its API requires: `Authorization: Bearer <token>` and the `anthropic-beta` header. See [Trigger a routine through the API](https://platform.claude.com/docs/api/claude-code/routines-fire) for the current values. The routine receives the delivery's `text` as context for its run.
* **T3 Code:** A scheduled task triggered by a webhook has a webhook URL. Its prompt can include fields from the delivery, such as `{{body.text}}`.
* **n8n:** Use a Webhook trigger node with the `POST` method. Its production URL receives requests only while the workflow is active. To verify signatures, turn on the node's Raw Body option.
* **Make:** Start a scenario with a custom webhook. Send a sample delivery while Make is determining the data structure, so later modules can map fields such as `links`.
* **Zapier:** Use Webhooks by Zapier. Catch Hook gives you fields to map; Catch Raw Hook keeps the raw body, which you need to verify signatures.

## What does each request look like?

Each delivery is one `POST` with these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `CurationSpace-Webhook/1 (+https://docs.curation.space/webhooks)` |
| `Idempotency-Key` | The delivery's `id`. |
| `X-Hub-Signature-256` | `sha256=` followed by the hex HMAC-SHA256 of the body. See [How do I verify a request?](#how-do-i-verify-a-request) |
| Your request headers | As you entered them. |

Requests go only to HTTPS URLs on public addresses. Each attempt times out after 10 seconds.

## What's in the body?

One JSON object: the delivery's ID, its channel, its links, and the whole delivery as plain text.

```json theme={"theme":"css-variables"}
{
  "id": "0f8e3c1a-6b2d-4c5e-9f7a-1d2b3c4d5e6f",
  "sample": false,
  "channel": {
    "id": "5f0c8a52-3b8e-4f0e-9a51-2f1d6c7e9b10",
    "name": "Weekend reading",
    "cadence": "Delta"
  },
  "linkCount": 1,
  "text": "Incoming Curation Space link delivery...\n\nDelivery channel name: Weekend reading\nDelivery channel cadence: Delta\nDelivery channel ID: 5f0c8a52-3b8e-4f0e-9a51-2f1d6c7e9b10\n\nLink count: 1\n\n---\n\n[Ada Lovelace](https://curation.space/ada) added a link to [Engines](https://curation.space/ada/engines)\n\nTitle: Sketch of the Analytical Engine\nAuthor: L. F. Menabrea\nURL: https://example.com/analytical-engine\nCurator's Note: Read the translator's notes; they are longer than the paper.",
  "links": [
    {
      "url": "https://example.com/analytical-engine",
      "title": "Sketch of the Analytical Engine",
      "author": "L. F. Menabrea",
      "curatorsNote": "Read the translator's notes; they are longer than the paper.",
      "curation": {
        "title": "Engines",
        "url": "https://curation.space/ada/engines"
      },
      "curator": {
        "name": "Ada Lovelace",
        "username": "ada",
        "url": "https://curation.space/ada"
      },
      "addedAt": "2026-10-17T14:03:12.000Z"
    }
  ]
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | The delivery's ID, the same on every attempt. |
| `sample` | boolean | `true` only for [sample deliveries](#can-i-send-a-sample-delivery). |
| `channel.id` | string | The delivery channel. |
| `channel.name` | string | The channel's name. |
| `channel.cadence` | `"Express"` \| `"Delta"` \| `"Resurfacer"` | Whether the channel delivers each new link shortly after it is added, new links at scheduled times, or links drawn at random from its sources at scheduled times. |
| `linkCount` | number | The number of entries in `links`, at least 1. |
| `text` | string | The whole delivery as plain text. See [What's in text?](#what’s-in-text) |
| `links[].url` | string | The link's URL. |
| `links[].title` | string | The title the curator set, otherwise the link's own title, otherwise its URL. |
| `links[].author` | string \| null | The author the curator set, otherwise the link's own author, or `null`. |
| `links[].curatorsNote` | string \| null | The curator's note, or `null`. |
| `links[].curation.title` | string | The curation the link was added to. |
| `links[].curation.url` | string \| null | The curation's page, or `null` when its curator has no username. |
| `links[].curator.name` | string | The curator who added the link. |
| `links[].curator.username` | string \| null | The curator's username, or `null` when they have none. |
| `links[].curator.url` | string \| null | The curator's profile, or `null` when they have no username. |
| `links[].addedAt` | string | When the link was added to the curation, as an ISO 8601 UTC time. |

`links` are in the order they were added. A delivery does not include the linked pages' text. Its content is written by curators: treat it as data, not as instructions to your receiver.

### What's in text?

The whole delivery, written for receivers that pass it to an agent as a prompt:

```text theme={"theme":"css-variables"}
Incoming Curation Space link delivery...

Delivery channel name: Weekend reading
Delivery channel cadence: Delta
Delivery channel ID: 5f0c8a52-3b8e-4f0e-9a51-2f1d6c7e9b10

Link count: 2

---

[Ada Lovelace](https://curation.space/ada) added a link to [Engines](https://curation.space/ada/engines)

Title: Sketch of the Analytical Engine
Author: L. F. Menabrea
URL: https://example.com/analytical-engine
Curator's Note: Read the translator's notes; they are longer than the paper.

---

[Ada Lovelace](https://curation.space/ada) added a link to [Engines](https://curation.space/ada/engines)

Title: On the \*Difference\* Engine
URL: https://example.com/difference-engine
```

* The `Author` and `Curator's Note` lines are left out when there is none.
* Entries are separated by `---`. The text contains no dates.
* Names and titles are each kept on one line, with Markdown characters escaped by a backslash. A curator or curation without a page appears as plain text instead of a link.
* The curator's note appears as written, including any line breaks.

## How do I verify a request?

Verification is optional. Every request is signed with the endpoint's signing secret, which is on the endpoint's page under **Verify requests (optional)**.

To check a request, compute the HMAC-SHA256 of the raw body bytes, exactly as received, using the signing secret's text as the key. Compare its hex digest with the part of `X-Hub-Signature-256` after `sha256=`, using a constant-time comparison. Do not parse and re-serialize the JSON first; the bytes would differ.

```ts Node.js theme={"theme":"css-variables"}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function isValidSignature(
  rawBody: Buffer,
  signature: string | undefined,
  secret: string
): boolean {
  if (!signature?.startsWith('sha256=')) return false;
  const expected = Buffer.from(
    createHmac('sha256', secret).update(rawBody).digest('hex')
  );
  const received = Buffer.from(signature.slice('sha256='.length));
  return received.length === expected.length &&
    timingSafeEqual(received, expected);
}
```

```python Python theme={"theme":"css-variables"}
import hashlib
import hmac


def is_valid_signature(raw_body: bytes, signature: str | None, secret: str) -> bool:
    if not signature or not signature.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.removeprefix("sha256="), expected)
```

**Generate new secret** replaces the signing secret immediately. Requests sent afterwards, including retries, are signed with the new secret.

## How should my receiver reply?

With a `2xx` status to accept the delivery. Every reply is read this way:

| Receiver reply | Outcome |
| - | - |
| `2xx`, `302`, `303` | Accepted by receiver. |
| `410`, `413` | Refused by receiver. That delivery is not sent again. |
| `301`, `307`, `308` | Retried. The endpoint's activity shows the host the receiver moved to, so you can update the webhook URL. |
| Any other status, a timeout, or a network failure | Retried. |

Redirects are never followed. Many form-style receivers answer a `POST` with `302` or `303`, so those count as accepted.

Retries start after 15 seconds and double each time, up to once an hour. They continue until the receiver accepts or refuses the delivery.

Acceptance confirms that your receiver got the delivery, not that its automation ran or succeeded.

### Can the same delivery arrive twice?

Yes. Delivery is at least once: your receiver can get the same delivery more than once, for example when it accepted a request but its reply did not arrive. Every attempt sends exactly the same body. To ignore repeats, deduplicate by `id` or `Idempotency-Key`.

## What happens to a large delivery?

It's split. Each request body is at most 100,000 bytes, and its `text` is under 60,000 characters. When more links are ready than fit, they arrive as consecutive deliveries, in order, each with its own `id` and `linkCount`. A single link too large to fit on its own is still sent, by itself.

## Can I send a sample delivery?

Yes. **Send sample delivery** on the endpoint's page sends a sample right away. A sample has `"sample": true` and two clearly synthetic links on `example.com`, with every field filled in. Its channel is named "Sample delivery channel" and has a placeholder ID.

Samples are listed separately from deliveries in the endpoint's activity, and cannot be sent while the endpoint is inactive. A sample may run your receiver's automation.

## What happens when I change the endpoint?

* **Deactivating the endpoint** stops sending through every channel that uses it. The channels keep making deliveries, which **Deliveries** and the endpoint's activity show as withheld. Deliveries the receiver has not accepted are withheld too. Withheld deliveries are never sent, even after you activate the endpoint again: activating it sends only deliveries made afterwards.
* **Replacing the URL or headers** keeps the endpoint, its signing secret, and its channels. Deliveries the receiver has not accepted, including those waiting to retry, go to the new URL immediately.
* **Moving a channel** to another Webhook or Agent endpoint takes the deliveries its receiver has not accepted along. Any that are too large for one webhook request are split into consecutive deliveries. Moving a channel to an email, SMS, or voice endpoint cancels them, and only links added afterwards are sent.
* **Deleting the endpoint** removes its delivery activity, including deliveries the receiver has not accepted. Its channels keep their sources and keep collecting links, but send nothing until you choose another endpoint for them. This cannot be undone.

## What are the limits?

| Field | Limit |
| - | - |
| URL | HTTPS; at most 2,048 characters; a query string is allowed; no user name, password, or `#` fragment. Hosts that are local names or private addresses are not accepted. |
| Name | Required; at most 100 characters. Names are labels: several endpoints can have the same name. |
| Request headers | At most 10, with names unique regardless of case. |
| Header name | A valid HTTP header name of at most 64 characters. |
| Header value | 1–4,096 printable ASCII characters. |
| Reserved header names | `Host`, `Content-Length`, `Content-Type`, `Transfer-Encoding`, `Connection`, `X-Hub-Signature-256`, `Idempotency-Key`, `User-Agent` |


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