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

# Agreement activity webhooks

> Use agreement.transitioned webhook events to mirror agreement lifecycle changes and reconcile current agreement state through the Agreements API.

For the complete documentation index, see [llms.txt](https://docs.shodai.network/llms.txt).

Agreement activity webhooks are `agreement.transitioned` events. Use them when your backend needs to react to agreement lifecycle changes without polling agreement state on a timer.

Treat each activity webhook as a compact signal: verify it, store and dedupe it, acknowledge it, and then read current agreement data from the Agreements API before updating your local mirror.

## Before you start

Create a webhook subscription that includes `agreement.transitioned`:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const webhook = await client.createWebhook({
  url: 'https://example.com/shodai/webhooks',
  eventTypes: ['agreement.transitioned'],
  filters: {
    templateIds: ['did:template:service-retainer-v0-1'],
  },
});
```

For signing, retries, test events, URL requirements, and shared filter semantics, see [Receive webhooks](/webhooks/receive-webhooks).

## When activity events are sent

Shodai sends `agreement.transitioned` for API-managed agreements owned by the API principal when:

* an agreement deploys and the deployment response includes a post-deploy state
* a mined input changes the agreement state

Deploy transition events use `fromState: ""` and `inputId: "__deploy"`. If the post-deploy state is not available yet, Shodai skips the deploy transition event.

Input transition events are sent only when the mined input changes state. If an input is accepted but the agreement remains in the same state, no `agreement.transitioned` event is emitted for that input.

## Payload shape

An activity event includes compact transition data:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_123",
  "type": "agreement.transitioned",
  "apiVersion": "2026-06-01",
  "createdAt": "2026-06-02T18:00:00.000Z",
  "data": {
    "agreementId": "agr_123",
    "agreementName": "Advisory Retainer",
    "templateId": "did:template:service-retainer-v0-1",
    "fromState": "AWAITING_PAYMENT",
    "toState": "WORK_IN_PROGRESS",
    "inputId": "submitInitialPaymentProof"
  }
}
```

| Field           | Description                                                          |
| --------------- | -------------------------------------------------------------------- |
| `agreementId`   | Hosted agreement record ID.                                          |
| `agreementName` | Optional human-readable agreement display name.                      |
| `templateId`    | Agreement template ID from the agreement JSON metadata.              |
| `fromState`     | Previous lifecycle state. Deploy transitions use an empty string.    |
| `toState`       | New lifecycle state.                                                 |
| `inputId`       | Input that caused the transition. Deploy transitions use `__deploy`. |

The activity payload does not include the full agreement record, variables, participants, observers, or full input history.

## Reconcile after receipt

After verification and dedupe, read the current agreement data before updating local state:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { constructWebhookEvent } from '@cns-labs/agreements-api-client/webhooks';

const event = constructWebhookEvent(rawBody, headers, process.env.SHODAI_WEBHOOK_SECRET!);

await storeWebhookEvent(event);

if (event.type === 'agreement.transitioned') {
  const agreementId = event.data.agreementId;

  const agreement = await client.getAgreement(agreementId);
  const state = await client.getAgreementState(agreementId);
  const inputs = await client.listAgreementInputs(agreementId, { limit: 25 });

  await updateLocalAgreementMirror({
    agreement,
    state,
    inputs,
    lastWebhookEventId: event.id,
  });
}
```

Use `GET /v0/agreements/{id}` for the hosted agreement record, `GET /v0/agreements/{id}/state` for current lifecycle state, and `GET /v0/agreements/{id}/inputs` for input history.

This pattern keeps your integration resilient when events arrive more than once, arrive after your own write response, or do not contain enough data to update your local model directly.

## Filter activity events

Activity webhooks support these filters:

| Filter         | Matches            |
| -------------- | ------------------ |
| `agreementIds` | `data.agreementId` |
| `templateIds`  | `data.templateId`  |
| `inputIds`     | `data.inputId`     |
| `fromStates`   | `data.fromState`   |
| `toStates`     | `data.toState`     |

Filter values are exact string matches. Multiple values inside one filter field act like OR. Different filter fields combine like AND.

Use `inputIds: ["__deploy"]` to receive only deploy activity events for matching agreements and templates.

## Idempotency expectations

Webhook delivery is at-least-once. Your receiver should:

* store each event by `id`
* treat a repeated `id` as already received
* return `2xx` after durable receipt
* process reconciliation asynchronously when possible
* make local mirror updates idempotent

Activity events are compact and may arrive after your backend has already observed the same state through a write response or a manual refresh. Reconciliation should use the current API state as the source of truth.

## Related pages

* [Receive webhooks](/webhooks/receive-webhooks)
* [Notification webhooks](/webhooks/notification-webhooks)
* [Operate a Deployed Agreement](/workflow/operate-a-deployed-agreement)
* [TypeScript client](/sdks/typescript-client)
