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

# Receive webhooks

> Create an endpoint and subscription, then build a receiver that verifies, deduplicates, orders, and processes transaction and position events.

Build a receiver for Yield.xyz webhook deliveries: create an endpoint and subscription in the dashboard, then verify, deduplicate, and process each delivery. This guide covers the receiving side. See [Webhooks](/documentation/advanced-setup/webhooks) for the event model, delivery statuses, and retry schedule.

## Prerequisites

* The owner, admin, or operator role on the team, to create endpoints and subscriptions. Every role can view them. See [Roles and permissions](/documentation/advanced-setup/roles-permissions).
* A publicly reachable HTTPS URL you control, served by a handler that can read the raw, unparsed request body.

## Create an endpoint and subscription

**1. Create the endpoint**

In the project page's **Webhooks** section, click **Create endpoint** and fill in:

* **URL**: your receiver.
* **Secret (min 24 chars)**: between 24 and 255 characters. You choose this value; it isn't generated for you, and it isn't shown again after you save, so store it now.
* **Description** (optional).

Click **Create**. The endpoint starts enabled. Each endpoint row has an **Enabled** switch, an **Edit** button (URL, secret, description) and a **Delete** button; click **Show** to open its subscriptions and deliveries.

**2. Create a subscription**

With the endpoint open, click **Create subscription**, then expand **Advanced: resources & actions** to pick what it receives:

* **Resources**: `transaction`, `position`.
* **Actions**: `status_changed`.

A resource and an action combine into an event type: `transaction.status_changed` or `position.status_changed`. Those are the only two event types emitted today; select one or both resources. Click **Create**. If you need a webhook for something else, [contact the Yield.xyz team](mailto:hello@yield.xyz) and we will help get it added.

**3. Optionally narrow delivery with filters**

The subscription dialog also has a **filtersJson (JSON)** field, a JSON object matched against each event's `subject`. Every key you set must match; an omitted or empty key matches everything.

```json theme={null}
{ "networks": ["ethereum", "base"], "addresses": ["0xYOUR_WALLET_ADDRESS"] }
```

`filtersJson` accepts `yield_ids`, `addresses`, `balance_types`, and `networks`, each a list of strings. Values are compared as exact strings, so an address must match the casing Yield.xyz stores. For the two live event types, only `addresses` and `networks` narrow anything: both carry `network` and, when the action has a wallet address, `address` on their subject, but never a `yield_id` or `balance_type`. A filter on a key the subject doesn't carry never matches, so setting `yield_ids` or `balance_types` filters out every event.

> [!NOTE]
>
> Disabling an endpoint doesn't pause delivery: while it's disabled, every delivery for it, including ones already queued, is marked `dead` when Yield.xyz picks it up, and nothing is resent when you re-enable it. Disabling a subscription stops new deliveries from being created for it. To delete an endpoint, delete its subscriptions first.

## What a delivery looks like

Every delivery is one signed `POST` with a JSON body. These headers are set on every request:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Yield-Delivery` | This delivery's id. A retry of the same delivery reuses it. Test sends use a `test_` prefixed id. |
| `X-Yield-Event` | The resource: `transaction`, `position`, or `webhook` for a test send |
| `X-Yield-Action` | The action: `status_changed`, or `test` for a test send |
| `X-Yield-Timestamp` | Unix seconds when this attempt was signed |
| `X-Yield-Signature` | `sha256=<hex HMAC>` |

No sequence or idempotency header is sent; both live in the body. Yield.xyz doesn't set a dedicated `User-Agent`, so don't filter on one.

The body is the event envelope. Every live event has these top-level fields:

| Field | Value |
| - | - |
| `id` | The event's UUID. Unique per event, stable across retries and across endpoints. |
| `schema_version` | `1` |
| `resource`, `action`, `type` | `transaction`, `status_changed`, `transaction.status_changed`, or the `position` equivalents |
| `created_at` | ISO 8601 timestamp of when the event was recorded |
| `project_id` | Your project's id |
| `subject` | Who the event is about: `transaction_id` or `position_id`, plus `network` and, when known, `address` |
| `sequence` | A per-subject counter, starting at 1. See [Order with sequence](#order-with-sequence). |
| `idempotency_key` | A string that identifies this specific change. Two events never share one. |
| `data` | The resource's state at the time of the event |
| `previous`, `changes` | Optional. Present on some transaction events only: `previous` holds the prior `status`, `changes` holds `{ "status": { "from", "to" } }`. |
| `meta` | Optional diagnostic fields such as `source` and `environment`. Informational only; don't branch on it. |

## Verify the signature

Read the raw request body before any JSON-parsing middleware touches it. The signature covers the exact bytes sent (compact JSON, no whitespace), and re-serializing a parsed body won't reproduce them.

The signature is an HMAC-SHA256 over `{timestamp}.{rawBody}`: the `X-Yield-Timestamp` value, a literal `.`, then the raw body bytes. It's keyed with the endpoint's secret, hex-encoded, and prefixed `sha256=`. There is one secret and one signature per endpoint; a retry is signed fresh, with a new timestamp and signature over the same body. Compute the same value and compare it in constant time:

```typescript Verify a delivery theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyYieldSignature(
  rawBody: Buffer,
  timestamp: string,
  signatureHeader: string,
  secret: string,
): boolean {
  const expectedHex = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const expected = Buffer.from(`sha256=${expectedHex}`);
  const received = Buffer.from(signatureHeader);
  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

With Express, mount a raw body parser on the webhook route only, so `req.body` is the untouched `Buffer`:

```typescript Express receiver theme={null}
import express from "express";
import { verifyYieldSignature } from "./verify";

const app = express();
const secret = process.env.YIELD_WEBHOOK_SECRET!;
const maxAgeSeconds = 300;

app.post("/webhooks/yield", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body as Buffer;
  const timestamp = req.header("X-Yield-Timestamp") ?? "";
  const signature = req.header("X-Yield-Signature") ?? "";

  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > maxAgeSeconds) {
    res.status(400).end();
    return;
  }
  if (!verifyYieldSignature(rawBody, timestamp, signature, secret)) {
    res.status(401).end();
    return;
  }

  const event = JSON.parse(rawBody.toString("utf8"));
  // Queue the event for processing, keyed on event.id, then acknowledge.
  res.status(200).end();
});
```

> [!WARNING]
>
> Reject the request if verification fails, and reject a timestamp outside a tolerance you choose (five minutes is a common choice) to guard against replay. Yield.xyz doesn't enforce a tolerance for you. Never process a delivery before verifying its signature.

Changing the endpoint's secret takes effect immediately, including for retries of deliveries first attempted under the old secret. If you rotate, have the receiver accept both the old and new secret during the switch.

## Respond quickly

Yield.xyz waits up to 15 seconds for a response, and any 2xx status counts as delivered. Anything else, including a timeout, a connection error, or a non-2xx status, is a failed attempt that will be retried. Acknowledge with a 2xx as soon as you've verified the signature and durably queued the event, then do slower processing (database writes, downstream calls) after responding, not before.

Answer from the endpoint URL itself. A test send refuses redirects, and redirect handling on regular deliveries isn't something to depend on, so point the endpoint at its final URL.

## Deduplicate on the event id

Delivery isn't exactly once. The same event can reach you more than once:

* A retry after an attempt that timed out on Yield.xyz's side but succeeded on yours.
* Recovery after an interruption mid-send, which requeues the delivery.
* Two subscriptions on the same endpoint that both match the event. Each produces its own delivery, with its own `X-Yield-Delivery` id, carrying the same event.

Store each event's `id` and skip one you've already processed. The `id` is the same in every copy, which `X-Yield-Delivery` is not.

## Order with sequence

Retries and parallel sends mean deliveries for the same subject can arrive out of order. Each event carries a `sequence` that Yield.xyz allocates per subject within your project: one counter per transaction (keyed on `subject.transaction_id`) and one per position (keyed on `subject.position_id`). It starts at 1 and only ever increases for that subject, and it's the same number in every copy of the event, on every endpoint.

To apply events in order, keep the highest `sequence` you've processed per subject and ignore any event whose `sequence` is lower than or equal to it. Treat `data` as the state at the time of the event, not as an incremental update, so a newer event can simply overwrite what an older one wrote. Three notes:

* Gaps are normal. A number can be consumed without an event reaching you, so never wait for a missing sequence.
* Counters are per subject. A transaction's `sequence` says nothing about its position's `sequence`, and nothing about any other transaction.
* Terminal statuses win. `sequence` follows the order events were recorded, which can differ from the order of the statuses: in rare cases a transaction's `CONFIRMED` event gets a lower `sequence` than its `BROADCASTED` event. `CONFIRMED` and `FAILED` are terminal for a transaction, and `SUCCESS` and `FAILED` are terminal for a position, so never overwrite a terminal status with a non-terminal one, whatever the `sequence`.

If you need to confirm a transaction's latest state independently of webhook timing, re-read it with `GET /v1/transactions/{transactionId}` (see [Enter and exit flows](/guides/earn/enter-exit-flows)).

> [!NOTE]
>
> Webhooks report the same status transitions the Earn API exposes; nothing here is guaranteed exactly once or strictly ordered on arrival. Poll as the source of truth when a workflow depends on it, and use webhooks to avoid polling continuously.

## Payloads

Two event types are emitted today. Ids below are placeholders.

### transaction.status\_changed

Fires when a transaction's status becomes `BROADCASTED` (after you submit a hash or a signed transaction), `CONFIRMED` or `FAILED` (from on-chain tracking), or `FAILED` because a submit was rejected. Earlier statuses such as `CREATED` and `WAITING_FOR_SIGNATURE` don't fire a webhook.

```json transaction.status_changed theme={null}
{
  "id": "9b2f3c1e-6d7a-4f0b-9e21-3c5d8a7b1f42",
  "schema_version": 1,
  "resource": "transaction",
  "action": "status_changed",
  "type": "transaction.status_changed",
  "created_at": "2026-10-22T12:00:05.120Z",
  "project_id": "4e1c7a9d-2b3f-4a5e-8c6d-0f1e2d3c4b5a",
  "subject": {
    "transaction_id": "7c0a2e5b-1d4f-4b8c-a3e9-5f6d7c8b9a01",
    "network": "ethereum",
    "address": "0xYOUR_WALLET_ADDRESS"
  },
  "sequence": 2,
  "idempotency_key": "transaction.status_changed:7c0a2e5b-1d4f-4b8c-a3e9-5f6d7c8b9a01:0xYOUR_WALLET_ADDRESS:ethereum:status:CONFIRMED:hash:0xTRANSACTION_HASH",
  "data": {
    "id": "7c0a2e5b-1d4f-4b8c-a3e9-5f6d7c8b9a01",
    "network": "ethereum",
    "status": "CONFIRMED",
    "type": "STAKE",
    "hash": "0xTRANSACTION_HASH",
    "createdAt": "2026-10-22T11:59:40.000Z",
    "broadcastedAt": "2026-10-22T11:59:45.000Z",
    "error": null,
    "address": "0xYOUR_WALLET_ADDRESS"
  },
  "meta": { "source": "...", "environment": "..." }
}
```

`data.status` is one of `BROADCASTED`, `CONFIRMED`, or `FAILED`; `data.type` is the transaction type (`STAKE`, `UNSTAKE`, `CLAIM_REWARDS`, and so on); `data.error` carries the failure reason when one is known and is `null` otherwise, including on every `BROADCASTED` and `CONFIRMED` event. `address` is omitted from `subject` and `data` when the action has no wallet address.

The `BROADCASTED` event, and a `FAILED` event raised by a rejected submit, also carry the transition:

```json Extra fields on a submit-driven event theme={null}
{
  "previous": { "status": "SIGNED" },
  "changes": { "status": { "from": "SIGNED", "to": "BROADCASTED" } }
}
```

Events raised by on-chain tracking (`CONFIRMED`, and `FAILED` from a reverted transaction) omit `previous` and `changes`.

### position.status\_changed

Fires once when an action reaches a terminal status: `SUCCESS` when its last transaction confirms, or `FAILED` when a transaction fails on chain. An action that fails because a submit was rejected doesn't fire this event; you get only the `transaction.status_changed` `FAILED` event. The `position_id` is the action's id.

```json position.status_changed theme={null}
{
  "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
  "schema_version": 1,
  "resource": "position",
  "action": "status_changed",
  "type": "position.status_changed",
  "created_at": "2026-10-22T12:00:05.130Z",
  "project_id": "4e1c7a9d-2b3f-4a5e-8c6d-0f1e2d3c4b5a",
  "subject": {
    "position_id": "2d9e4b7a-8c1f-4e3a-b5d6-7a8b9c0d1e2f",
    "address": "0xYOUR_WALLET_ADDRESS",
    "network": "ethereum"
  },
  "sequence": 1,
  "idempotency_key": "position.status_changed:2d9e4b7a-8c1f-4e3a-b5d6-7a8b9c0d1e2f:0xYOUR_WALLET_ADDRESS:ethereum:status:SUCCESS:id:2d9e4b7a-8c1f-4e3a-b5d6-7a8b9c0d1e2f",
  "data": {
    "position_id": "2d9e4b7a-8c1f-4e3a-b5d6-7a8b9c0d1e2f",
    "status": "SUCCESS",
    "completedAt": "2026-10-22T12:00:05.100Z",
    "integrationId": "ethereum-eth-lido-staking",
    "address": "0xYOUR_WALLET_ADDRESS",
    "type": "STAKE",
    "validatorAddress": "0xVALIDATOR_ADDRESS"
  },
  "meta": { "source": "...", "environment": "..." }
}
```

`data.status` is `SUCCESS` or `FAILED`; `data.type` is the action type (`STAKE`, `UNSTAKE`, `CLAIM_REWARDS`, and so on); `data.integrationId` is the yield id. `validatorAddress` appears only when the action targets a validator, and `address` only when the action has a wallet address. Position events never carry `previous` or `changes`.

## Retries and dead deliveries

A failed attempt is retried with increasing waits: 5 minutes, then 15 minutes, 1 hour, 6 hours, and 24 hours between each later attempt, with no jitter. After the tenth failed attempt the delivery is marked `dead` and not sent again; there is no way to replay a dead delivery today, so reconcile by re-reading the resource. See [Webhooks](/documentation/advanced-setup/webhooks) for the full schedule and statuses.

## Send a test event

A test send posts a signed `webhook.test` event to an endpoint and returns the result immediately. It uses the same headers and signing scheme as a real delivery, works on a disabled endpoint, matches no subscription, and isn't written to the delivery history. Redirects aren't followed, so a 3xx comes back as a failed test. The receiver has 5 seconds to answer, and the endpoint must be a public address: a private, loopback, or link-local target, or a public hostname that resolves to one, is rejected with `412`.

The dashboard doesn't have a test button yet. The test send is exposed by the dashboard API, for a signed-in user with the owner, admin, or operator role. It authenticates with your dashboard session, not a project or admin API key, so it's meant for checking an endpoint by hand rather than for calling from your integration:

```http theme={null}
POST /v2/teams/{teamId}/projects/{projectId}/webhooks/endpoints/{endpointId}/test
```

The body your receiver gets has no `subject`, `sequence`, or `idempotency_key`. Tell it apart from real traffic by `type`, and return a 2xx so the test passes:

```json webhook.test theme={null}
{
  "id": "evt_test_5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
  "schema_version": 1,
  "resource": "webhook",
  "action": "test",
  "type": "webhook.test",
  "created_at": "2026-10-22T12:00:00.000Z",
  "project_id": "4e1c7a9d-2b3f-4a5e-8c6d-0f1e2d3c4b5a",
  "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "data": { "message": "This is a test event from Yield. No resource changed." },
  "meta": { "source": "test_send" }
}
```

The response is the whole record of the test: `delivered` (true on a 2xx within the timeout), `httpStatus` (or `null` if the endpoint never answered), `durationMs`, `timeoutMs`, `errorCode` (`timeout`, `dns`, `conn_refused`, `network`, `non_2xx`, or `unknown`), `errorMessage`, `responseSnippet` (the first 1000 characters of your response body), `testId` (the `X-Yield-Delivery` value the ping carried), and `sentAt`.

## Verify

After creating an endpoint and subscription, trigger an action so a matching event fires, then open the endpoint with **Show** and click **Show deliveries**. The table lists deliveries newest first with **Status** (`pending`, `sending`, `delivered`, `retry_scheduled`, or `dead`), **Attempts**, **Last HTTP**, **Last error**, and **Last attempt**. Click **Info** on a row to open the stored event: its type, schema version, sequence, and the `meta`, `data`, and `changes` objects exactly as sent. A `delivered` row with a 2xx under **Last HTTP** confirms your receiver is reachable, verifies the signature correctly, and responds within the timeout.

## Next steps

> **[Webhooks](/documentation/advanced-setup/webhooks)**
>
> The event model, delivery statuses, and retry schedule.

> **[Enter and exit flows](/guides/earn/enter-exit-flows)**
>
> Poll transactions directly instead of, or alongside, webhooks.

> **[Monitoring and alerting](/guides/earn/monitoring-alerting)**
>
> Combine webhooks with polling to keep position state current.

> **[Manage team roles](/guides/dashboards/rbac)**
>
> Who can create and manage webhook endpoints.


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