# Webhooks

What Yutai's webhooks send: the kinds of change they tell a distributor about, what one delivery carries, how often a failed delivery is tried again, and how a delivery is checked. The values on this page are read from Yutai's own rules when the site is built, so they cannot drift from them.

Yutai does not deliver to real endpoints yet. The deliveries it records run on simulated data, so no change reaches a real address; the rules on this page are the ones its stored deliveries already follow.

## Kinds of change

Every delivery tells a distributor about one change, and every change is one kind. A distributor asks for the kinds of change it wants to hear about; the kinds Yutai's registry names:

Tier change `tier_changed`: A holder's tier changed: the credential the holder carries now names another tier.

Benefit unlock `benefit_unlocked`: A benefit opened through a tier: the benefit and the tier that opens it.

Claim confirmation `claim_confirmed`: A claim a holder made is recorded: the claim and the benefit it was made on.

Redemption record `redemption_recorded`: A redemption of a claim is recorded.

Bank link connected `connect_linked`: A person's bank account is linked through Connect: the distributor's own reference for that person.

Bank link disconnected `connect_revoked`: A person's bank link stops: the distributor's own reference for that person.

Owner and customer status changed `owner_customer_changed`: Whether a person counts as an owner-customer of a company changed: the distributor's own reference, the company and the new answer.

## What a delivery carries

One delivery carries three facts: the event id, the kind and the payload. The bounds are the contracts' own:

`eventId`: The change's own id. It names one change and never changes across attempts, so you can tell a repeat from a new change. It carries 1 to 256 characters of letters, digits, dot, underscore, colon and hyphen.

`kind`: The kind of change the delivery tells about: one of the kinds above.

`payload`: The change's own facts as flat entries. A key carries 1 to 64 characters of letters, digits, underscore, dot and hyphen. A value is a string of up to 512 characters, a finite number, a boolean or null. At most 32 entries.

Each kind names the payload it carries, as flat entries:

Tier change payload

```
{
  "asOf": "2026-10-08T12:00:00.000Z",
  "credentialId": "crd_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E",
  "holderId": "hld_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E",
  "previousTier": "Tier 1",
  "tier": "Tier 2"
}
```

Benefit unlock payload

```
{
  "benefitId": "bft_01J9ZQ5B7C8D9E0F1G2H3J4K5M",
  "tier": "Tier 2"
}
```

Claim confirmation payload

```
{
  "claimId": "clm_01J9ZQ6N8P9Q0R1S2T3V4W5X6Y",
  "benefitId": "bft_01J9ZQ5B7C8D9E0F1G2H3J4K5M"
}
```

Redemption record payload

```
{
  "redemptionId": "red_01J9ZQ7P9R0S1T2V3W4X5Y6Z8A",
  "claimId": "clm_01J9ZQ6N8P9Q0R1S2T3V4W5X6Y"
}
```

Bank link connected payload

```
{
  "external_ref": "customer-4471"
}
```

Bank link disconnected payload

```
{
  "external_ref": "customer-4471"
}
```

Owner and customer status changed payload

```
{
  "external_ref": "customer-4471",
  "company_id": "cmp_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E",
  "owner_customer": true
}
```

## Delivery rules

How a delivery reaches an endpoint:

At least once: A distributor may receive the same change more than once: a retry after an answer that was slow, or a redelivery. Yutai never promises exactly once.

One delivery per change: A redelivered change is the same delivery: Yutai keeps one delivery per change and endpoint, so a repeat answers the same delivery with the same payload. Record the event ids you have processed and answer a repeat without acting on it again.

No order promised: Changes can arrive in any order, and a later change can arrive before an earlier one after a retry. A distributor that needs the current state reads it from Yutai with the calls the [API reference](https://docs.yutai.com/api) names rather than replaying deliveries in arrival order.

## Retries

Each attempt answers `delivered` or `failed`: a failed answer carries the reason it failed, a delivered one carries none. A failed attempt is tried again on the schedule below; a delivered one ends the delivery.

Attempt 1: when the change is sent

Attempt 2: 1 minute later

Attempt 3: 5 minutes later

Attempt 4: 30 minutes later

Attempt 5: 2 hours later

A delivery that has spent its 5 attempts stays failed and is not tried again on its own. Yutai's team can send a spent delivery again by hand, at most 3 times, each named beside the operator who pressed it.

## The signature

A receiver checks a delivery's signature in the `Yutai-Signature` header. The value carries two parts joined by a comma: `t` is the whole number of unix seconds the delivery was signed at, and `v1` is the keyed hash, 64 lowercase hex characters. For example `Yutai-Signature: t=1781000000,v1=2fc9d3550390166c913528cae3e8469e437e5e9642d7f94f2a808dd72eda3c7a`.

A receiver checks the timestamp first: only a header whose `t` sits within 300 seconds of the receiver's own clock, either way, is fresh, so a captured delivery cannot be replayed later and pass.

The header's `v1` is a keyed hash over the raw body's exact bytes, not over a message built from the delivery: HMAC-SHA256 under the subscription's signing key over `t`, a dot and the raw body bytes as received, never a body written again after parsing. The example header above signs this body:

The exact body bytes signed

```
{
  "eventId": "tier-update-1",
  "kind": "tier_changed",
  "payload": {
    "asOf": "2026-10-08T12:00:00.000Z",
    "credentialId": "crd_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E",
    "holderId": "hld_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E",
    "previousTier": "Tier 1",
    "tier": "Tier 2"
  }
}
```

Today's stored deliveries carry the simulated digest proof instead, and that proof binds the delivery's own message rather than the header's `v1`:

The message the signature binds

```
Yutai webhook delivery
Tenant: tnt_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E
Event: tier-update-1
Kind: tier_changed
Endpoint: https://hooks.example.test/yutai
Payload: {"asOf":"2026-10-08T12:00:00.000Z","credentialId":"crd_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E","holderId":"hld_01J9ZQ4V8W7X6Y5Z4A3B2C1D0E","previousTier":"Tier 1","tier":"Tier 2"}
```

The real signing key is not issued yet. Until it is, the signature a stored delivery carries is the simulated digest proof over the message above, not the header's `v1`, and no header is sent. The header and its `v1` are an example computed with a demo key.

## Check a delivery

A receiver checks a delivery in this order, before it trusts or reads any of the body:

1. Read the `Yutai-Signature` header. Refuse a delivery whose `t` is missing, repeated or not a whole number of seconds, and whose `v1` is missing, repeated or not 64 lowercase hex characters. Refuse a header over 1024 characters, and a part that is not a name and a value. Any other field in the header is ignored, so a later scheme can ride beside `v1`.
2. Refuse a `t` further than 300 seconds from your own clock, either way. That stops a captured delivery being replayed later.
3. Recompute HMAC-SHA256 under the subscription's signing key over `t`, a dot and the exact raw body bytes as received, before any JSON parsing, then compare the answer to `v1` in constant time. A body written again after parsing can differ by a byte and must never be the bytes you check.
4. Only then keep one record per event id: a repeat of an event id you have already processed is the same change, so answer it without acting on it again.

Two more rules hold across attempts and across a key change:

- Each attempt is signed at its own send instant, so a retry carries a fresh `t` and the tolerance applies to that attempt, not to the first one.
- After a key change, a receiver may accept either the old or the new signing key for an overlap window it chooses.

The real signing key is not issued yet, so until it is no header is sent, and a receiver cannot verify a real delivery yet.
