# Endpoint URLs

An endpoint URL turns each JSON event a service sends into a note in one thread: form answers, alerts, build results, GitHub pull requests or Slack messages. The service needs no token and no code from you, only the URL.

## What an endpoint URL is

- The URL is the credential. Anyone who has it can add notes to its thread, so keep it like a password.
- It adds notes to the one thread the owner chose. An event can’t choose another thread.
- The owner creates it in ThreadPage, as a connection of its own on Add only. The API can’t create one.
- It needs Pro with Sync turned on. Without them, events answer `409` and are not added.
- The URL, and its secret if it has one, are shown once. **Replace URL…** gives new ones, and the old ones stop working at once.
- It needs no permission for a shared thread: the owner chose that thread.
- A connection has up to 10 endpoint URLs.

To create one: in **Settings → Connections**, go to **Automations** and choose **Connect an automation** → **Add notes from another app**. Choose the thread and what the service sends, then copy the URL and any secret into the service.

## Presets

| Preset in ThreadPage | `preset` | The service sends | Each event becomes |
|---|---|---|---|
| **Text or Markdown** | `text` | `{"text": "…"}` or `{"markdown": "…"}`, with an optional `id` | a note, as written (`text`) or formatted (`markdown`) |
| **GitHub pull requests** | `github` | GitHub `pull_request` events | a note when a pull request is opened, reopened, ready for review, merged or closed, with its title, link, repository, author and time; other events are `ignored` |
| **GitHub releases** | `github_release` | GitHub `release` events | a note when a release is published, with its name (or tag), link and release notes; other release actions are `ignored` |
| **Slack messages** | `slack` | Slack Events API | a note for each new message, as Slack shows it, with its links and formatting; edits, deletions and joins are `ignored`; Slack’s URL check is answered |
| **Custom template** | `custom` | any JSON object | your Markdown template, filled with fields of the event |

## Send an event

With **Text or Markdown**, send `text` (kept as written) or `markdown` (formatted). An `id` makes a retried event count once:

```bash
curl -X POST "$THREADPAGE_ENDPOINT_URL" \
  -H "Content-Type: application/json" \
  -d '{"text": "Build 412 passed."}'
```

```bash
curl -X POST "$THREADPAGE_ENDPOINT_URL" \
  -H "Content-Type: application/json" \
  -d '{"id": "build-412", "markdown": "**Build 412** passed. [Logs](https://ci.example.com/412)"}'
```

```json
{"outcome": "accepted", "note_id": "4c2f8e19-7a6b-4d3e-b1c0-95e8f2a7d364", "diagnostic_id": "5b3e9a0c7d1f4e2a8b6c0d9e1f2a3b4c"}
```

### Send an event to an endpoint URL

`POST /in/{capability}`

The owner adds an endpoint URL to a custom connection whose level adds notes (Add only, Read and add or Edit and organize) in Settings → Connections; each event becomes a note in the one thread they chose. The URL is the credential: no token is sent, and it is shown once. Signed endpoints check the signature over the raw body first (Standard Webhooks, GitHub X-Hub-Signature-256 or Slack v0, within five minutes). The preset decides which events become notes and how a repeated event is recognised for 30 days: the webhook-id header where nothing is signed or a Standard Webhooks signature covers it; otherwise the event's id in the body (id; event_id for Slack, whose signature covers the body only, so its webhook-id header is ignored), and for GitHub the SHA-256 of the signed body. Every answer except Slack's URL check has outcome and diagnostic_id, which is also the X-Request-ID header; an added note has its note_id.

**Requirements**

| Requirement | Value |
|---|---|
| Credential | The endpoint URL itself; no token. |

**Parameters**

| Name | In | Type | Description |
|---|---|---|---|
| `capability` | path | string, required | The secret part of the endpoint URL. |

Answer `200`: Slack's URL check, answered with its challenge.

**Answer fields**

| Field | Type | Description |
|---|---|---|
| `challenge` | string, required | Slack's challenge, sent back. |

```bash
curl -m 30 -X POST "$ENDPOINT_URL" \
  -H "Content-Type: application/json" \
  -d '{"id":"build-412","markdown":"**Build 412** passed on `main`."}'
```

Errors: `reconnect_required` 403, 409, `write_not_allowed` 403, 409, `not_found` 404, `connection_unavailable` 409, `invalid_json` 400, `content_too_large` 413, `unsupported_media_type` 415, `thread_archived` 409, `invalid_signature` 401, `secret_unavailable` 401, `method_not_allowed` 405, `endpoint_paused` 503, `missing_field` 422, `empty_note` 422.

## Template syntax

A template is Markdown with fields from the event, written as JSON Pointers in double braces.

| Write | Inserts |
|---|---|
| `{{/name}}` | the field; the event must have it |
| `{{/company?}}` | the field, or nothing when it is missing |
| `{{/profile\|url}}` | the field as a link |
| `{{/created_at\|timestamp}}` | the field as a readable UTC time |
| `{{/summary\|markdown}}` | the field with its own Markdown formatting |

Other values are inserted as plain text, and `null` inserts nothing. The dialog in ThreadPage previews the note from a sample event.

```markdown
New lead: **{{/name}}**
{{/company?}}
{{/profile|url}}
Received {{/created_at|timestamp}}
```

An event that lacks a required field answers `422`.

## Signing

An endpoint can require a signature over the raw body, checked before anything is parsed. A timestamp must be within 5 minutes.

| Preset | Signature |
|---|---|
| **Text or Markdown**, **Custom template** | none, or Standard Webhooks: `webhook-id`, `webhook-timestamp` and `webhook-signature`, with the `whsec_` secret ThreadPage made. Each event needs its own `webhook-id`. |
| **GitHub pull requests** | GitHub’s `X-Hub-Signature-256`, with the secret ThreadPage made for it. |
| **Slack messages** | Slack’s `v0` signature, with your Slack app’s signing secret. |

A Standard Webhooks request, signed the way ThreadPage signs its own webhooks:

JavaScript:

```javascript
import crypto from 'node:crypto';

const secret = process.env.THREADPAGE_ENDPOINT_SECRET; // whsec_…
const body = JSON.stringify({ id: 'build-412', markdown: '**Build 412** passed.' });
const id = `msg_${crypto.randomUUID()}`; // one per event, the same when you retry it
const timestamp = String(Math.floor(Date.now() / 1000));
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signature = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64');

const answer = await fetch(process.env.THREADPAGE_ENDPOINT_URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'webhook-id': id,
    'webhook-timestamp': timestamp,
    'webhook-signature': `v1,${signature}`,
  },
  body,
});
console.log(answer.status, await answer.json());
```

Python:

```python
import base64, hashlib, hmac, json, os, time, uuid
import requests  # pip install requests

secret = os.environ["THREADPAGE_ENDPOINT_SECRET"]  # whsec_…
body = json.dumps({"id": "build-412", "markdown": "**Build 412** passed."}).encode()
message_id = f"msg_{uuid.uuid4()}"  # one per event, the same when you retry it
timestamp = str(int(time.time()))
key = base64.b64decode(secret.removeprefix("whsec_"))
signature = base64.b64encode(hmac.new(key, f"{message_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()).decode()

answer = requests.post(
    os.environ["THREADPAGE_ENDPOINT_URL"],
    data=body,
    headers={
        "Content-Type": "application/json",
        "webhook-id": message_id,
        "webhook-timestamp": timestamp,
        "webhook-signature": f"v1,{signature}",
    },
    timeout=30,
)
print(answer.status_code, answer.json())
```

## Duplicates

ThreadPage remembers an event for 30 days and adds it once:

- by its `webhook-id`, when the endpoint is unsigned or a Standard Webhooks signature covers it;
- otherwise by the event’s own ID: `id` in the body, or `event_id` for Slack;
- for GitHub, by the SHA-256 of the signed body.

A repeat answers `202` with `outcome: "duplicate"`.

## Answers

Every answer except Slack’s URL check is JSON with `outcome` and `diagnostic_id`, which is also the `X-Request-ID` header. An added note also has its `note_id`; a refusal has `code` and `message`.

| `outcome` | Means |
|---|---|
| `accepted` | The note was added. |
| `duplicate` | The same event was added before. |
| `ignored` | Not an event this endpoint adds, such as a GitHub ping or an edited Slack message. |
| `rejected` | The request itself was refused: no such endpoint, a paused one, a bad signature, or a body that is not JSON. |
| `blocked` | The event is fine, but the note can’t be added now: a limit, or a thread or connection that can’t take notes. |
| `failed` | The event can’t become a note: a field the template needs is missing, or the note would be empty or too large. |

| Status | Means |
|---|---|
| `200` | Slack’s URL check, answered with its `challenge`. |
| `202` | `accepted`, `duplicate` or `ignored`. |
| `400` | The body is not one JSON object, repeats a key or nests too deeply. |
| `401` | The signature or its `webhook-id` is missing, or the signature is wrong or older than 5 minutes. |
| `404` | No such endpoint: the URL was replaced or deleted, or the connection was disconnected. |
| `405` | Not sent with `POST`. |
| `409` | Not added: the thread is in Trash, archived or outside the connection’s access, or the connection can’t add notes now. `token_expired` means the connection’s token ended: the owner chooses **Renew**. `reconnect_required` means the token was retired: the owner chooses **Replace token…**. |
| `413` | The body is over 256 KiB, the note would be over 256 KiB, or its Markdown over 32 KiB. |
| `415` | Not sent as uncompressed JSON. |
| `422` | The event has no `text` or `markdown`, lacks a field the template needs, or makes an empty note. |
| `429` | A limit was reached: `rate_limited` with `details.reason` and `details.scope`. Wait `Retry-After` seconds. |
| `503` | The endpoint is paused, the request took too long (`timed_out`) or ThreadPage is busy. Wait `Retry-After` seconds. |

Each endpoint’s **Events** list in ThreadPage shows its last 20 outcomes, and its row says when the latest event failed. ThreadPage keeps no copy of an event, only its outcome. Events refused while the endpoint is paused or over a limit are not kept to add later, and many services don’t send them again.

## Limits

| Limit | Value |
|---|---|
| An event’s body | 256 KiB |
| A `markdown` field, or a template’s note | 32 KiB |
| Plain `text` | the 256 KiB of a note |
| Events, all of a connection’s endpoint URLs together | 60 a minute (`events_minute`) |
| Images | not taken; HTML stays text, and links work only for `http` and `https` |

Each note also counts toward the connection’s [write limits](https://thread.page/help/connections/limits.md).

## Set up GitHub

1. Create the endpoint with **GitHub pull requests** and copy its URL and secret.
2. In the repository’s **Settings → Webhooks**, paste the URL as **Payload URL**, choose Content type `application/json`, paste the secret, and pick **Pull requests**.

GitHub does not resend failed deliveries by itself; use **Redeliver** in the webhook’s **Recent Deliveries**.

## Set up Slack

1. Create the endpoint with **Slack messages**, and give ThreadPage your Slack app’s signing secret.
2. In your Slack app’s **Event Subscriptions**, paste the URL as the **Request URL**. ThreadPage answers Slack’s URL check.

## From n8n, Make or Zapier

Use an HTTP or webhook step: method `POST`, body type JSON, the `text` or `markdown` field, and an `id` built from the item, so a retried run adds the note once.

## If it fails

**Errors**

| Code | HTTP | When | What to do | Retryable |
|---|---|---|---|---|
| `invalid_signature` | 401 | The signature or its webhook-id is missing, wrong or older than 5 minutes. | Sign the raw body with the endpoint's secret, with a fresh timestamp. | No |
| `secret_unavailable` | 401 | The endpoint can no longer check signatures. | Ask the owner to add the endpoint again. | No |
| `method_not_allowed` | 405 | Not sent with POST. | Send POST. | No |
| `endpoint_paused` | 503 | The owner paused the endpoint; the event is not kept. | Ask the owner to resume it, then send the event again. | Yes |
| `missing_field` | 422 | The event lacks a field the template needs. | Send the field, or mark it optional with ? in the template. | No |
| `empty_note` | 422 | The event makes an empty note. | Send text or markdown. | No |

## Next

- [Events and subscriptions](https://thread.page/help/connections/events.md): the other direction, events out of ThreadPage.
- [Rate limits](https://thread.page/help/connections/limits.md)
