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

# Track a business account transaction

> Follow one business account payment from the policy checks and approvals through to confirmation on chain.

<Note>
  Business Accounts are in **early access**. Transaction quorum currently covers EVM. Solana and Bitcoin transaction support is coming soon. See [Approvals for signing](/docs/business-accounts/approvals#approvals-for-signing).
</Note>

## Introduction

This cookbook covers a treasury wallet on a business account that pays an allowlisted vendor. The payment has to pass three [policy](/docs/business-accounts/policies) rules before the wallet can sign:

* The account allows only that vendor's address.
* The wallet caps how much one transaction can send.
* At or above the amount named in a [quorum policy](/docs/business-accounts/approvals#approvals-for-signing), two admins have to approve. The member who starts the payment does not count as one of them.

```mermaid theme={"system"}
flowchart LR
  pay(["Payment submitted"]) --> policy{"Address and<br/>amount allowed?"}
  policy -- "No" --> blocked(["Blocked"])
  policy -- "Yes" --> approvals{"Two admin<br/>approvals met?"}
  approvals -- "Vetoed or expired" --> closed(["Closed"])
  approvals -- "Met" --> signed["Wallet signs"]
  signed --> broadcast["Broadcast"]
  broadcast --> landed(["Landed"])
  broadcast -- "Reverted or failed" --> unlanded(["Not landed"])

  classDef start fill:#e0e7ff,stroke:#6366f1,color:#312e81
  classDef decision fill:#fef3c7,stroke:#d97706,color:#451a03
  classDef step fill:#f1f5f9,stroke:#64748b,color:#0f172a
  classDef success fill:#dcfce7,stroke:#16a34a,color:#052e16
  classDef failure fill:#fee2e2,stroke:#dc2626,color:#450a0a

  class pay start
  class policy,approvals decision
  class signed,broadcast step
  class landed success
  class blocked,closed,unlanded failure
```

Your goal is to build a system to track every stage the transaction goes through, surface any issues, and ensure any approvers see that they need to do so.

We will start by identifying the right events to track, then create a webhook for them, record each step and build an approver inbox. Along the way we will also cover some best practices around event recovery and security.

## Event types

Here are the events the payment moves through, in order.

| What happened | Event |
| - | - |
| The address or the amount is blocked | `waas.policy.violation` |
| The two approvals are still required | `businessAccount.proposal.created` |
| An admin approves | `businessAccount.proposal.approved` |
| An admin takes an approval back | `businessAccount.proposal.approvalWithdrawn` |
| An admin vetoes the proposal | `businessAccount.proposal.vetoed` |
| The proposal misses its deadline | `businessAccount.proposal.expired` |
| The initiator resumes and the wallet signs | `businessAccount.proposal.executed` |
| Dynamic hands the signed transaction to a broadcaster | `wallet.transaction.broadcasting` |
| The transaction is on chain and waiting | `wallet.transaction.confirming` |
| The transaction landed | `wallet.transaction.completed` |
| The transaction landed and the contract rejected it | `wallet.transaction.reverted` |
| The transaction never reached the chain | `wallet.transaction.failed` |

What each event tells you in context comes up in the sections below. The full payload shapes are in [Policy violation webhooks](/docs/embedded-wallets/mpc/policies/violation-webhooks), [Business account proposal webhooks](/docs/business-accounts/webhooks/proposals), and [Transaction events](/docs/embedded-wallets/transaction-events).

## Subscribe to the payment

One webhook covers the whole flow. Subscribe it to every event in the table.

<Tabs>
  <Tab title="Dashboard">
    Open [Developer > Webhooks](https://console.dynamic.xyz/dashboard/developer/webhooks), create a webhook pointing at your HTTPS endpoint, and select the `businessAccount.proposal.*` events, `waas.policy.violation`, and the `wallet.transaction.*` events.
  </Tab>

  <Tab title="CLI">
    The command uses the environment selected in the [Dynamic CLI](/docs/cli). `dyn status` shows which one that is.

    ```bash theme={"system"}
    dyn webhooks create \
      --url https://your-domain.example/dynamic-webhooks \
      --is-enabled true \
      --events \
        waas.policy.violation \
        businessAccount.proposal.created \
        businessAccount.proposal.approved \
        businessAccount.proposal.approvalWithdrawn \
        businessAccount.proposal.vetoed \
        businessAccount.proposal.executed \
        businessAccount.proposal.expired \
        wallet.transaction.broadcasting \
        wallet.transaction.confirming \
        wallet.transaction.completed \
        wallet.transaction.reverted \
        wallet.transaction.failed
    ```

    The command prints the created webhook, including its signing `secret`. Read that secret again later with `dyn webhooks get <webhookId> --include-secret true`.
  </Tab>

  <Tab title="API">
    ```bash theme={"system"}
    curl --request POST \
      --url https://app.dynamic.xyz/api/v0/environments/$DYNAMIC_ENVIRONMENT_ID/webhooks \
      --header "Authorization: Bearer $DYNAMIC_API_TOKEN" \
      --header "Content-Type: application/json" \
      --data '{
        "url": "https://your-domain.example/dynamic-webhooks",
        "isEnabled": true,
        "events": [
          "waas.policy.violation",
          "businessAccount.proposal.created",
          "businessAccount.proposal.approved",
          "businessAccount.proposal.approvalWithdrawn",
          "businessAccount.proposal.vetoed",
          "businessAccount.proposal.executed",
          "businessAccount.proposal.expired",
          "wallet.transaction.broadcasting",
          "wallet.transaction.confirming",
          "wallet.transaction.completed",
          "wallet.transaction.reverted",
          "wallet.transaction.failed"
        ]
      }'
    ```

    Creating or updating a webhook requires an API token with webhook write permission and is a paid feature. The response carries the webhook `secret` used for signature verification.
  </Tab>
</Tabs>

Dynamic sends a `ping` test payload when the webhook registers. See [Setting up webhooks](/docs/platform/dashboard/webhooks/setup) for the full procedure.

## Record each step

Before trusting a message, check two things. The signature: `x-dynamic-signature-256` should be the HMAC-SHA256 of the raw body, computed with the webhook's secret. And whether you have seen it before: messages arrive at least once, so keep a record of each `messageId` you process and drop the repeats. Both mechanics are covered in [Setting up webhooks](/docs/platform/dashboard/webhooks/setup) and [Event delivery & best practices](/docs/platform/dashboard/webhooks/delivery-best-practices).

```javascript theme={"system"}
const handlePaymentEvent = async ({ eventName, data }) => {
  switch (eventName) {
    case 'waas.policy.violation':
      await markPaymentBlocked(data);
      break;
    case 'businessAccount.proposal.created':
    case 'businessAccount.proposal.approved': {
      // pendingEligibleApprovers is everyone still worth asking.
      // mandatoryApprovers is the subset who cannot be skipped.
      await notifyApprovers({
        userIds: data.pendingEligibleApprovers,
        mustApprove: data.mandatoryApprovers,
        needed: data.evaluationSummary.minimumAdditionalApprovals,
        proposalId: data.proposalId,
        deadline: data.completeBy,
      });
      break;
    }
    case 'businessAccount.proposal.approvalWithdrawn':
      await notifyApprovers({
        userIds: data.pendingEligibleApprovers,
        proposalId: data.proposalId,
      });
      break;
    case 'businessAccount.proposal.executed':
      await markSignatureReady(data.proposalId);
      break;
    case 'businessAccount.proposal.vetoed':
    case 'businessAccount.proposal.expired':
      await markProposalClosed(data.proposalId, eventName);
      break;
    case 'wallet.transaction.completed':
      await markPaymentLanded(data);
      break;
    case 'wallet.transaction.reverted':
    case 'wallet.transaction.failed':
      await markPaymentUnlanded(data, eventName);
      break;
  }
};
```

The `notify` and `mark` calls stand in for your own code. Map the Dynamic user IDs to your members, and send the notification over whatever channel they watch: email, Slack, or your own push.

Three payload details matter while the approvals are still open:

* `evaluationSummary.isSatisfied` tells you whether the wallet can sign; `minimumAdditionalApprovals` tells you how many approvals are still missing. Do not add up `requiredCount` across `ruleEvaluations`: a single approval counts toward every rule its approver is eligible for. See [Reading a proposal payload](/docs/business-accounts/webhooks/proposals) for the full field list.
* Once a proposal reaches `vetoed`, `expired`, or `executed`, `pendingEligibleApprovers`, `mandatoryApprovers`, and `ruleEvaluations` report empty. Capture your routing targets on `created` and `approved`, not at close.
* `businessAccount.proposal.expired` fires shortly after the `completeBy` deadline, not at the deadline itself, and carries `expiredAt` instead of an actor.

Events can also arrive out of order, so trust the `timestamp` and the final status over what came in when: an `executed` you never saw an `approved` for is still the signature; a `completed` you never saw a `broadcasting` for is still the landing.

## Confirm the payment landed

`businessAccount.proposal.executed` means the approvals are in and the wallet signed. What you hold at that point is a signed transaction that has not reached the chain yet. How it gets there decides which events you will see:

* **Dynamic broadcasts it** on the gas-sponsored EVM path. Then `wallet.transaction.completed` means it landed, `reverted` means the chain saw it but the contract refused it, and `failed` means it never arrived. `broadcasting` and `confirming` can be skipped, so only those three count.
* **Your application broadcasts it.** Then none of the `wallet.transaction.*` events fire at all, and your own chain watcher is what confirms the landing.

Either way, the landing event does not carry the proposal id, so keep your own link from `proposalId` to the transaction.

## Reconcile missed events

Messages get missed: an endpoint outage, a disabled webhook, a deploy at the wrong moment. The events feed is the same stream, pulled instead of pushed, so poll it on a schedule or after re-enabling a webhook to replay what was missed. `resourceType` picks which events you want by matching the start of the event name: `businessAccount.proposal` for the approvals, `waas.policy` for a block, `wallet.transaction` for a payment Dynamic broadcast.

```bash theme={"system"}
curl "https://app.dynamic.xyz/api/v0/environments/$DYNAMIC_ENVIRONMENT_ID/events?resourceType=businessAccount.proposal&startDate=2026-09-01" \
  --header "Authorization: Bearer $DYNAMIC_API_TOKEN"
```

```javascript theme={"system"}
const seen = new Set(/* eventIds already processed */);
let cursor;
do {
  const url = new URL(`https://app.dynamic.xyz/api/v0/environments/${environmentId}/events`);
  url.searchParams.set('resourceType', 'businessAccount.proposal');
  if (cursor) url.searchParams.set('cursor', cursor);
  const page = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  }).then((res) => res.json());
  for (const event of page.data) {
    if (!seen.has(event.eventId)) {
      await handlePaymentEvent(event);
      seen.add(event.eventId);
    }
  }
  cursor = page.cursor;
} while (cursor);
```

Reading the feed needs an API token with events read permission; see [API token permissions](/docs/platform/dashboard/api-token-permissions) for assigning scopes. A few limits apply:

* `startDate` can look back at most 30 days, and events are kept for 30 days in sandbox and 90 days in live.
* Results are unordered, so sort by `timestamp` yourself.
* The same `eventId` can show up in both the feed and a delivered webhook, so dedupe both against the same seen set.

To replay one specific delivery instead, use `POST /environments/{environmentId}/webhooks/{webhookId}/messages/{messageId}/redeliver`.

## Show an admin their approval queue

The webhook tells you the payment is waiting. The admin's own inbox should use the SDK, which returns only the proposals that member can act on.

`serializedTransaction` is the unsigned payment your application prepared, and `walletAccount` is the treasury wallet that will sign it.

```javascript theme={"system"}
import {
  isSigningConsentRequiredError,
  listBusinessAccountProposals,
  listBusinessAccountProposalsAwaitingMyApproval,
  signSerializedTransaction,
} from '@dynamic-labs-sdk/client/waas';

// Every pending proposal awaiting this member's approval, across all their
// business accounts.
const awaitingMe = await listBusinessAccountProposalsAwaitingMyApproval();

// This account's pending proposals, including the vendor payment.
const pending = await listBusinessAccountProposals({
  businessAccountId: 'ba_123',
  status: 'pending',
});

// The quorum rule rejects the first signing call and opens the proposal.
try {
  await signSerializedTransaction({ serializedTransaction, walletAccount });
} catch (error) {
  if (!isSigningConsentRequiredError(error)) {
    throw error;
  }
  console.log('Opened proposal', error.proposalId);
}
```

A `BusinessAccountProposal` tells you its `status` (`pending`, `executed`, `vetoed`, or `expired`), its `completeBy` deadline, `approvalRequirements`, and `intent`. To see whether it can still be satisfied, read `approvalRequirements[].satisfied` rather than counting who has already approved: that `approvals` list includes people whose approval no longer counts. Resuming the signature is covered in [Quorum policies](/docs/javascript/reference/business-accounts/policies/quorum-policies).

## Operational notes

* Your endpoint must respond within 15 seconds or the delivery counts as failed and is retried. Accept messages fast and process them asynchronously.
* If deliveries keep failing, Dynamic switches the webhook off: 200 failures in a row, or 250 in sandbox and 1500 in live within 30 days. Turning it back on is manual, so alert on that state and reconcile through the events feed once it is back.
* An environment supports 10 webhooks, and messages are retained for 30 days in sandbox and 90 days in live. The full delivery contract is in [Event delivery & best practices](/docs/platform/dashboard/webhooks/delivery-best-practices).

## Related pages

* [Approvals](/docs/business-accounts/approvals)
* [Business account proposal webhooks](/docs/business-accounts/webhooks/proposals)
* [Policy violation webhooks](/docs/embedded-wallets/mpc/policies/violation-webhooks)
* [Transaction events](/docs/embedded-wallets/transaction-events)
