> For the complete documentation index, see [llms.txt](https://docs.usepylon.com/pylon-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.usepylon.com/pylon-docs/developer/webhooks.md).

# Webhooks

Receive webhooks for various events happening in Pylon.

{% hint style="info" %}
Webhooks are user-defined HTTP callbacks that are triggered by specific events within Pylon.
{% endhint %}

This page covers webhooks that Pylon sends to you.

## Setup

1. Go to **Settings > Integrations & dev >** [**Webhooks**](https://app.usepylon.com/settings/webhooks) and click **Create Webhook**.
2. Enter a **Name** and the **Target URL**, and pick the HTTP method (`POST` by default; `GET`, `PUT`, `PATCH`, and `DELETE` are also available).
3. Optionally, add **Custom headers**, for example an `Authorization` header with a Bearer token. Turn on **Secret** for a header to encrypt its value and hide it after saving.
4. Click **Create** and copy the webhook secret. Pylon shows it only once. You need it to [verify deliveries](/pylon-docs/developer/webhooks.md#verifying-webhooks).
5. Go to **Settings > Workflows >** [**Triggers**](https://app.usepylon.com/settings/triggers) and create a [trigger](/pylon-docs/platform/triggers.md) with a **Send webhook** action. Use the trigger's conditions to control when the webhook fires. Macros can also send webhooks.

The target must be a public `http` or `https` URL. Private and internal network addresses are blocked. If your endpoint sits behind a firewall, allowlist [Pylon's IP addresses](/pylon-docs/security/ip-addresses.md) for your region.

## Payload

In the **Send webhook** action, write the request body in **JSON payload**. Type `{` to insert variables such as `{{ issue.id }}` or `{{ issue.title }}`. The variables you can use depend on the trigger's object. See [Template variables](/pylon-docs/platform/triggers/template-variables.md) for the full list, including tags, the latest message, and its author.

<figure><img src="https://982123570-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F9hXBoCTTNw5flARlUVJf%2Fuploads%2FCH27ESeHYF0T8nRFuvbX%2FCleanShot%202024-10-10%20at%2018.21.58%402x.png?alt=media&#x26;token=1e9227f9-0035-446d-986a-fc2be2cb8f83" alt=""><figcaption></figcaption></figure>

Follow these rules when you write the payload:

* The payload must be valid JSON before variables are filled in.
* Put every variable inside a JSON string. Pylon escapes each value for JSON but doesn't add quotes, so `"id": "{{issue.id}}"` works and `"id": {{issue.id}}` does not.
* Every value arrives as a string, including numbers, dates, and `true`/`false`. Convert them on your side if needed.
* Lists such as `{{ issue.tags }}` arrive as one comma-separated string, and missing values arrive as `""`.

```json
{
  "issue_id": "{{ issue.id }}",
  "number": "{{ issue.number }}",
  "title": "{{ issue.title }}",
  "tags": "{{ issue.tags }}",
  "requester_email": "{{ issue.requester.email }}",
  "last_message_id": "{{ issue.last_message.id }}",
  "last_message_html": "{{ issue.last_message.body_html }}",
  "last_message_author": "{{ issue.last_message.author.name }}"
}
```

You can also use variables in the webhook's **Target URL**, for example `https://example.com/hooks/{{ issue.account.id }}`. Variables in the URL are not URL-encoded, so use URL-safe values such as IDs.

## Delivery and retries

* **Timeout:** each attempt waits up to 20 seconds for a response.
* **Success:** any status below `400` counts as delivered. A successful delivery adds a "sent webhook" entry to the activity of the object that fired the trigger.
* **Retries:** Pylon makes up to 3 attempts in total. If an attempt gets a `4xx` or `5xx` status, times out, or can't connect, Pylon retries after about 4 seconds, then again after about 8 seconds. Retries include the `X-Pylon-Retry-Number` header.
* **At-least-once:** a delivery can arrive more than once, for example when your endpoint processes a request but responds too slowly. Make your handler idempotent.
* **No ordering guarantee:** deliveries can arrive in a different order from the events that caused them.
* **No event ID header:** to de-duplicate, include an identifier such as `{{issue.id}}` together with the field that changed in your payload.

Pylon's own headers (`Content-Type`, the signature and timestamp headers, and `X-Pylon-Retry-Number`) replace any custom header with the same name. Every request is sent with `Content-Type: application/json`.

## Test and monitor

* Click **Test** in the create or edit dialog to send a test request with the body `{"data":{"type":"ping"}}`. Tests sent while creating a webhook are unsigned, because the secret doesn't exist yet. Tests sent from **Edit** on a saved webhook are signed.
* The **Logs** tab on the Webhooks page lists every attempt with its status, method, payload, response, duration, and request ID. Retries appear as separate entries. Payloads and responses over about 65 KB are truncated in the log.

## Verifying webhooks

When you create a webhook, Pylon shows its secret once. Store it securely: Pylon uses it to sign every delivery from a trigger or macro, and every test sent from a saved webhook, so your endpoint can confirm the request came from Pylon and has not been tampered with or replayed.

The secret can't be viewed again or rotated. To replace it, create a new webhook, point your triggers at it, and delete the old one.

Each delivery includes these headers:

| Header                 | Value                                                                            |
| ---------------------- | -------------------------------------------------------------------------------- |
| `X-Pylon-Timestamp`    | The time the request was sent, in Unix seconds.                                  |
| `X-Pylon-Signature-V2` | Hex-encoded HMAC-SHA256 of `<timestamp>.<body>`, keyed with your webhook secret. |
| `X-Pylon-Signature`    | Legacy. Hex-encoded HMAC-SHA256 of the body only.                                |
| `X-Pylon-Retry-Number` | Present on retries only: `1` on the first retry, `2` on the second.              |

To verify a delivery:

1. Read `X-Pylon-Timestamp` and reject the request if it is more than 5 minutes from your server's current time.
2. Build the signed string by joining the timestamp, a period (`.`), and the **raw** request body exactly as received, before any JSON parsing.
3. Compute the HMAC-SHA256 of that string using your webhook secret as the key, and hex-encode it. Use the secret string as-is; do not hex-decode it.
4. Compare your result to `X-Pylon-Signature-V2` using a constant-time comparison.

Retries are signed again with a fresh timestamp, so a retried delivery passes the timestamp check.

{% tabs %}
{% tab title="Python" %}

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60

def verify_pylon_webhook(secret: str, headers, raw_body: bytes) -> bool:
    timestamp = headers.get("X-Pylon-Timestamp")
    signature = headers.get("X-Pylon-Signature-V2")
    if not timestamp or not signature:
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

{% endtab %}

{% tab title="Node.js" %}

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

const TOLERANCE_SECONDS = 5 * 60;

function verifyPylonWebhook(secret, headers, rawBody) {
  const timestamp = headers["x-pylon-timestamp"];
  const signature = headers["x-pylon-signature-v2"];
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

{% endtab %}
{% endtabs %}

{% hint style="warning" %}
`X-Pylon-Signature` signs only the body, so a captured request verifies indefinitely. It is still sent for existing integrations, but new integrations should verify `X-Pylon-Signature-V2` together with the timestamp.
{% endhint %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

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

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.usepylon.com/pylon-docs/developer/webhooks.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

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.
