Skip to main content
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 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.
  • 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 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.
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.
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: 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:

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:
Verify a delivery
With Express, mount a raw body parser on the webhook route only, so req.body is the untouched Buffer:
Express receiver
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).
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.
transaction.status_changed
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:
Extra fields on a submit-driven event
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.
position.status_changed
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 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:
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:
webhook.test
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

The event model, delivery statuses, and retry schedule.

Enter and exit flows

Poll transactions directly instead of, or alongside, webhooks.

Monitoring and alerting

Combine webhooks with polling to keep position state current.

Manage team roles

Who can create and manage webhook endpoints.