> ## 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.

# Notification webhooks

> Use agreement.notification.triggered webhook events when Shodai should evaluate agreement notification rules and call your backend with the resolved notification content.

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

Notification webhooks are `agreement.notification.triggered` events. They use the same webhook subscription, signing, delivery, retry, and filtering system as agreement activity webhooks, but they are created by the notification rules attached to an agreement.

Use notification webhooks when Shodai should evaluate agreement notification rules while your backend owns the final side effect, such as sending email through your own SES account.

## How notification webhooks work

There are two required setup steps:

1. Create a webhook subscription that includes `agreement.notification.triggered`.
2. Deploy an agreement with an agreement-scoped `notificationTemplate` whose rules use `notification.channel: "external_webhook"`.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const webhook = await client.createWebhook({
  url: 'https://example.com/shodai/webhooks',
  eventTypes: ['agreement.transitioned', 'agreement.notification.triggered'],
});
```

A subscription alone does not create notification events. Shodai sends `agreement.notification.triggered` only after an `external_webhook` notification rule resolves at least one recipient.

## Attach notification rules at deploy

Pass `notificationTemplate` to `deployWithPermit`:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
await client.deployWithPermit({
  agreement,
  displayName: 'Consulting Agreement',
  chainId: 59141,
  signer,
  deadline,
  signature,
  notificationTemplate: {
    rules: [
      {
        id: 'deployment-follow-up',
        name: 'Deployment follow-up',
        trigger: { type: 'onTransition', inputs: ['__deploy'] },
        recipients: ['@observers'],
        notification: {
          channel: 'external_webhook',
          subject: 'Agreement deployed',
          body: 'Agreement ${agreementId} is ready for review.',
        },
      },
    ],
  },
});
```

After a successful deploy, the External API scopes the template to the authenticated Shodai account, deployed agreement ID, and agreement template ID. It does not replay a deploy transition. The deploy request returns after the transaction receipt is mined, while the returned chain attribution is still provisional.

Shodai evaluates `__deploy` notification rules later, from the canonical deploy transition produced by finalized chain projection. Projection uses the shared finality pin at `head - 20`, so this notification does not arrive synchronously with the deploy response.

## onTransition rules

`onTransition` rules evaluate when an agreement transition event reaches the notification service.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const notificationTemplate = {
  rules: [
    {
      id: 'payment-received',
      name: 'Payment received',
      trigger: {
        type: 'onTransition',
        from: ['AWAITING_PAYMENT'],
        to: ['WORK_IN_PROGRESS'],
        inputs: ['submitInitialPaymentProof'],
      },
      recipients: ['clientWalletAddress', '@observers'],
      notification: {
        channel: 'external_webhook',
        subject: 'Payment received for ${agreementName}',
        title: 'Work can begin',
        body: '${agreementName} moved to ${toState}.',
        ctaLabel: 'View agreement',
      },
    },
  ],
};
```

For `onTransition` triggers:

* `from`, `to`, and `inputs` are optional arrays
* omitted arrays act as wildcards
* `inputs: ["__deploy"]` matches the finalized deploy transition when chain projection emits it
* `fromState`, `toState`, and `input` are available for interpolation
* the webhook payload includes a `transition` block

## Temporal rules

Temporal rules evaluate while an agreement is in one of the configured states.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const notificationTemplate = {
  rules: [
    {
      id: 'invoice-due-soon',
      name: 'Invoice due soon',
      trigger: {
        type: 'temporal',
        states: ['AWAITING_PAYMENT'],
        condition: {
          type: 'deadlineApproaching',
          variable: 'paymentDueAt',
          threshold: { value: 2, unit: 'days' },
        },
        fireOnce: true,
      },
      recipients: ['customerWalletAddress'],
      notification: {
        channel: 'external_webhook',
        subject: 'Payment due soon for ${agreementName}',
        body: 'Payment is due soon for agreement ${agreementId}.',
      },
      constraints: {
        maxOccurrences: 1,
        cooldown: { value: 1, unit: 'days' },
      },
    },
  ],
};
```

Temporal triggers support:

| Field | Behavior |
| - | - |
| `states` | States where the temporal rule is active. |
| `condition.type: "deadlineApproaching"` | Fires when a date variable is within the threshold and still in the future. |
| `condition.type: "stateAge"` | Fires when time in the current state reaches the threshold. |
| `condition.type: "elapsedSinceVariable"` | Fires when elapsed time since a date variable reaches the threshold. |
| `threshold.unit` | `seconds`, `minutes`, `hours`, `days`, or `weeks`. |
| `fireOnce` | Defaults to `true`; when true, fires once per state visit. |
| `constraints.maxOccurrences` | Caps temporal firings for the rule and agreement. One firing counts once, regardless of recipient fan-out. |
| `constraints.cooldown` | Requires a minimum time between temporal sends for the same rule and agreement. |

`checkInterval` is accepted and stored on temporal triggers, but current hosted evaluation uses a system-wide temporal sweep. Do not rely on `checkInterval` as a per-rule schedule or cadence guarantee.

Each due temporal evaluation has one firing identity before recipients and channels are resolved. Re-evaluating the same firing does not create another logical notification. Each resolved recipient and channel receives its own notification effect.

Temporal notification payloads do not include a `transition` block.

## Recipient resolution

Each notification rule has `recipients`. Shodai resolves those tokens before emitting notification webhook events.

| Recipient token | Resolution behavior |
| - | - |
| Variable key, such as `customerWalletAddress` | Reads that variable value and resolves it to an email. |
| `*` | Scans agreement variable values for wallet-address-shaped strings and resolves each one. |
| `@observers` | Uses observer email addresses on the agreement. |

Resolved raw values are converted to email addresses in this order:

1. participant email matching the wallet address or matching the variable key
2. companion email variable, such as `customerEmail` for `customerWalletAddress`
3. direct email value, when the raw value itself contains an email address

Literal email strings are not collected when placed directly in `recipients`. To notify a literal email address, pass it as an observer or as the value of a recipient variable.

Resolved email addresses are deduped case-insensitively. Unresolved recipients are skipped and logged.

## Content interpolation

`subject`, `title`, and `body` support `${variable}` interpolation.

For `onTransition` notifications, interpolation context includes the agreement variables plus:

* `agreementName`
* `agreementId`
* `fromState`
* `toState`
* `input`

For temporal notifications, interpolation context includes the stored agreement variable snapshot plus:

* `agreementName`
* `agreementId`

Unknown variables remain in the rendered string as `${variableName}`.

## Payload shape

An `onTransition` notification webhook includes notification content and transition context:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "effect_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "type": "agreement.notification.triggered",
  "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",
    "notificationTemplateId": "external:principal-1:agr_123",
    "ruleId": "payment-received",
    "triggerType": "onTransition",
    "recipient": "client@example.com",
    "notification": {
      "subject": "Payment received for Advisory Retainer",
      "title": "Work can begin",
      "body": "Advisory Retainer moved to WORK_IN_PROGRESS.",
      "ctaLabel": "View agreement"
    },
    "variables": {
      "agreementName": "Advisory Retainer",
      "agreementId": "agr_123",
      "fromState": "AWAITING_PAYMENT",
      "toState": "WORK_IN_PROGRESS",
      "input": "submitInitialPaymentProof"
    },
    "transition": {
      "fromState": "AWAITING_PAYMENT",
      "toState": "WORK_IN_PROGRESS",
      "inputId": "submitInitialPaymentProof",
      "occurredAt": "2026-06-02T17:59:58.000Z"
    }
  }
}
```

A temporal notification webhook omits `transition`:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "effect_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
  "type": "agreement.notification.triggered",
  "apiVersion": "2026-06-01",
  "createdAt": "2026-06-04T18:00:00.000Z",
  "data": {
    "agreementId": "agr_123",
    "agreementName": "Advisory Retainer",
    "templateId": "did:template:service-retainer-v0-1",
    "notificationTemplateId": "external:principal-1:agr_123",
    "ruleId": "invoice-due-soon",
    "triggerType": "temporal",
    "recipient": "client@example.com",
    "notification": {
      "subject": "Payment due soon for Advisory Retainer",
      "body": "Payment is due soon for agreement agr_123."
    },
    "variables": {
      "agreementName": "Advisory Retainer",
      "agreementId": "agr_123",
      "paymentDueAt": "2026-06-06T18:00:00.000Z"
    }
  }
}
```

One resolved rule-recipient pair produces one `agreement.notification.triggered` event. That event is delivered as signed POST requests to each active webhook subscription that matches the event type and filters. A delivery's opaque `id` remains the same across retries and redeliveries, so receivers can dedupe repeated attempts. Different subscriptions receive distinct delivery IDs.

## Filter notification events

Notification webhooks support these filters:

| Filter | Matches |
| - | - |
| `agreementIds` | `data.agreementId` |
| `templateIds` | `data.templateId` |
| `ruleIds` | `data.ruleId` |
| `inputIds` | `data.transition.inputId` when present |
| `fromStates` | `data.transition.fromState` when present |
| `toStates` | `data.transition.toState` when present |

Temporal notification payloads do not include `transition`, so they do not match `inputIds`, `fromStates`, or `toStates` filters.

## Handle notification events

Your receiver handles notification webhooks like any other Shodai webhook:

1. verify the signature with the subscription secret
2. store and dedupe by event `id`
3. return `2xx` after durable receipt
4. perform your final side effect asynchronously

For example, the Shodai Reference App converts vendored notification templates to `external_webhook`, lets hosted Shodai services evaluate transition and temporal rules, receives `agreement.notification.triggered`, and sends final email through its own AWS SES configuration.

## Related pages

* [Receive webhooks](/webhooks/receive-webhooks)
* [Agreement activity webhooks](/webhooks/agreement-activity-webhooks)
* [Shodai Reference App](/examples/reference-app)
* [TypeScript client](/sdks/typescript-client)


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