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

# Webhooks

> Signed event notifications that tell your backend a resource changed, and how to receive them.

Webhooks tell your backend that a resource may have changed. They are a delivery channel, not a ledger, and they are the cheapest way to avoid polling everything.

## How it works

1. **Create an endpoint** for an app environment in the Partner Dashboard. Stableyard shows its signing secret once.
2. **A resource changes**, such as a payment succeeding or a deposit settling, and Stableyard records an event.
3. **Stableyard sends the event** as a signed `POST` to every active endpoint in that app environment subscribed to the event name.
4. **Your handler verifies and records it**, returns `2xx`, then reads the resource for its current state.

## An event is a prompt to read

An event says something changed. The resource says what is true now. Events are additive, may be delivered more than once and may arrive out of order, so never reconstruct state from delivery order: fetch the resource, such as `GET /v2/payments/{paymentId}`, and act on what it returns. See [Reconciliation](/payments/reconciliation).

New partner events are added over time. Your consumer must ignore an unknown event name safely rather than throw.

## Creating an endpoint

Create and manage endpoints in the Partner Dashboard, under **Developers → Webhooks**. Each endpoint belongs to one app environment.

| Setting | Rules |
| - | - |
| Endpoint URL | Public HTTPS, up to 2,048 characters, no embedded credentials. Unique within the app environment. It cannot be changed later: create a new endpoint for a new URL |
| Description | Optional, up to 512 characters |
| Events | Up to 100 partner event names from the [Event catalog](/webhooks/event-catalog). Choose none to receive every partner event for the environment |

Stableyard sends partner-facing lifecycle events for every account owned by that app environment. A second endpoint with the same URL in the same app environment is refused.

<Warning>
  Store the signing secret immediately. The dashboard shows it once, at creation, and never again. If you lose it, rotate the endpoint's secret, which invalidates the previous one at once. See [Verifying signatures](/webhooks/verifying-signatures#rotating-the-signing-secret).
</Warning>

## Managing an endpoint

| Action in **Developers → Webhooks** | What it does |
| - | - |
| Edit | Changes the description or the subscribed events |
| Disable or enable | A disabled endpoint receives no new events until it is enabled again |
| Archive | Retires the endpoint for good. An archived endpoint cannot be changed or reactivated: create a new one |
| Rotate signing secret | Shows a new signing secret once. See [Verifying signatures](/webhooks/verifying-signatures#rotating-the-signing-secret) |

Each endpoint's deliveries and their outcomes are under **Developers → Event stream**. See [Delivery and retries](/webhooks/delivery-and-retries#inspect-deliveries). Disabling, archiving or unsubscribing changes what happens to deliveries still waiting. See [Delivery and retries](/webhooks/delivery-and-retries#endpoint-changes-and-pending-deliveries).

## Delivery guarantees

Delivery is **at-least-once**, so the same event may arrive again. Dedupe on two levels:

* By `x-stableyard-delivery` for delivery-level retries, which reuse the same delivery attempt.
* By `x-stableyard-event-id` plus the resource ID in the payload for resource-level processing.

If one endpoint succeeds while another subscribed to the same event fails, the successful endpoint is not sent the event again. An app environment may have only one active endpoint per URL.

Failed deliveries are retried with backoff, then abandoned. See [Delivery and retries](/webhooks/delivery-and-retries).

## What a delivery contains

Every delivery is a `POST` with a JSON body and these headers:

| Header | Contents |
| - | - |
| `x-stableyard-event` | Event name |
| `x-stableyard-event-id` | Stable event ID |
| `x-stableyard-delivery` | Delivery ID |
| `x-stableyard-account-id` | The account ID for account-bound events. Omitted for public wallet Payments |
| `x-stableyard-external-user-id` | Percent-encoded partner user ID, when available |
| `x-stableyard-timestamp` | Unix timestamp |
| `x-stableyard-signature` | `t=<timestamp>,v1=<hmac_sha256>` |

```json theme={"system"}
{
  "id": "outbox_123",
  "name": "deposit.settled",
  "apiVersion": "2026-09-09",
  "createdAt": "2026-10-01T12:05:00Z",
  "payload": { "accountId": "acct_123", "depositId": "deposit_123" }
}
```

Every envelope contains `id`, `name`, `apiVersion`, `createdAt` and `payload`. `id` is the event's stable ID, the same value as `x-stableyard-event-id`. Treat it as an opaque string. `apiVersion` is the immutable date-version of the resource and event contract; use it to select your decoder rather than inferring the version from delivery time. What each `payload` carries is in the [Event catalog](/webhooks/event-catalog).

Events for public wallet Payments carry no account ID, in the payload or the headers.

## A minimal handler

1. Read the raw request bytes. Do not parse JSON first.
2. Verify the signature. See [Verifying signatures](/webhooks/verifying-signatures).
3. In one database transaction, record `x-stableyard-event-id` under a unique constraint and apply the event. On a conflict, skip the business effect.
4. Return `2xx` after the transaction commits.
5. Read the resource before acting on anything user-visible.

```js theme={"system"}
import express from "express";
import { verifyStableyardWebhook } from "./verify-stableyard-webhook.js";

const app = express();

app.post("/webhooks/stableyard", express.raw({ type: "application/json" }), async (req, res) => {
  let verified;
  try {
    verified = verifyStableyardWebhook(req.body, req.headers, process.env.STABLEYARD_WEBHOOK_SECRET);
  } catch {
    return res.status(400).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));
  await recordAndApply(verified.eventId, event); // your code: unique on eventId, one transaction
  return res.status(200).end();
});
```

A `5xx` from your handler is retried; a `400` is abandoned at once. Return `5xx` when your own database is down, so the event comes back.

## Related

<CardGroup cols={2}>
  <Card title="Event catalog" icon="list" href="/webhooks/event-catalog">
    Every partner event, what it means and which to subscribe to.
  </Card>

  <Card title="Verifying signatures" icon="shield-check" href="/webhooks/verifying-signatures">
    Check each delivery's HMAC-SHA256 signature before you trust it.
  </Card>

  <Card title="Delivery and retries" icon="rotate" href="/webhooks/delivery-and-retries">
    Retries, backoff, abandonment and requeueing a delivery.
  </Card>

  <Card title="Reconciliation" icon="scale-balanced" href="/payments/reconciliation">
    Why an event is a prompt to read the resource, and what to persist alongside the event ID.
  </Card>
</CardGroup>


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