> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.solvimon.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.solvimon.com/_mcp/server.

## Summary

Credit grants on a pricing item can now scale with the number of seats purchased. Seat occupancy is tracked as its own resource through the new seat assignments API, and a wallet can show which customer's usage reserved which part of its balance. Also: a credit-amount wallet's tax category is now always set, workflow emails include the template variables the legacy integration used to inject, and draft workflows can be deleted.

## Pricing

**You can now leave product items unpriced**
One product can back several plans that each charge for a different subset of its items: leave the rest unpriced, and events matching those items produce no charge on that plan. The API always allowed a pricing to skip product items of its product; Desk was what enforced pricing every item. Now you can remove a product item from a pricing on a pricing plan version, a rate card version, or a schedule pricing, which makes [filtered product items](/platform-guides/products-and-pricing/pricing-plans#leaving-a-product-item-unpriced) usable in practice. An unpriced item still renders as an "Unpriced" card with an "Add pricing" action, so leaving one out stays visible rather than silent.

## Wallets and credits

**Credit grants that scale with seats**
A wallet-grant rule on a pricing item config declares what its quantity is derived from through a new `source` field, so a plan sold per seat can hand out credits per seat without a separate rule per customer. `FIXED` is the default and grants the quantity exactly as configured, which is what every existing rule does. `SEAT_POOL` multiplies the configured quantity by the applicable purchased seat count and grants the result into the shared customer wallet as a single grant per rule per billing period, never one grant per seat. The seat count comes from `seats_values` on the pricing plan schedule for that pricing item, falling back to `default_seats_value` on the item config when the schedule sets none.

`SEAT_POOL` is only valid on a `PER_SEAT` pricing item, since seat counts are scoped to a pricing item rather than to the schedule as a whole; the API rejects it elsewhere with a validation error on `wallet_grants`. Issued grants carry the same `source` read-only, so a grant that was seat-scaled is identifiable after the fact. The enum also declares `PER_SEAT`, for credits owned by an individual seat rather than pooled; it is not implemented yet and is rejected with `PER_SEAT is not supported yet`. See [Seat-based credits](/platform-guides/wallets-credits/seat-based-credits) for the full configuration, including how the seat count is resolved and how seats added mid-period are topped up.

In Desk, the grant source is configured on the wallet grant in a pricing, and a seat-scaled grant shows the seat count it resolved against.

**See whose usage reserved a wallet balance**
A shared credit pool spent by several child customers reported one reserved figure with no way to see where it came from. The wallet balance status can now return `customer_details`, a breakdown of the reserved balance per customer, requested with `include_customer_details` on the balance call. Each entry carries `customer_id` and that customer's share of the reserved balance. The split is recorded when an invoice is built or refreshed, so it reflects the usage that has actually been drafted against the wallet.

### Request

POST [https://test.api.solvimon.com/v\{version}/wallets/\{resourceIdOrReference}/balance](https://test.api.solvimon.com/v\{version}/wallets/\{resourceIdOrReference}/balance)

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```curl Wallets_postWalletsByResourceIdOrReferenceBalance_example
curl -X POST https://test.api.solvimon.com/v1/wallets//balance \
     -H "X-API-KEY: <apiKey>" \
     -H "Content-Type: application/json" \
     -d '{
  "balance_at": "2026-10-09T12:00:00Z"
}'
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```python Wallets_postWalletsByResourceIdOrReferenceBalance_example
import requests

url = "https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance"

payload = { "balance_at": "2026-10-09T12:00:00Z" }
headers = {
    "X-API-KEY": "<apiKey>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```javascript Wallets_postWalletsByResourceIdOrReferenceBalance_example
const url = 'https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance';
const options = {
  method: 'POST',
  headers: {'X-API-KEY': '<apiKey>', 'Content-Type': 'application/json'},
  body: '{"balance_at":"2026-10-09T12:00:00Z"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```go Wallets_postWalletsByResourceIdOrReferenceBalance_example
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance"

	payload := strings.NewReader("{\n  \"balance_at\": \"2026-10-09T12:00:00Z\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("X-API-KEY", "<apiKey>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```ruby Wallets_postWalletsByResourceIdOrReferenceBalance_example
require 'uri'
require 'net/http'

url = URI("https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["X-API-KEY"] = '<apiKey>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"balance_at\": \"2026-10-09T12:00:00Z\"\n}"

response = http.request(request)
puts response.read_body
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```java Wallets_postWalletsByResourceIdOrReferenceBalance_example
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance")
  .header("X-API-KEY", "<apiKey>")
  .header("Content-Type", "application/json")
  .body("{\n  \"balance_at\": \"2026-10-09T12:00:00Z\"\n}")
  .asString();
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```php Wallets_postWalletsByResourceIdOrReferenceBalance_example
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance', [
  'body' => '{
  "balance_at": "2026-10-09T12:00:00Z"
}',
  'headers' => [
    'Content-Type' => 'application/json',
    'X-API-KEY' => '<apiKey>',
  ],
]);

echo $response->getBody();
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```csharp Wallets_postWalletsByResourceIdOrReferenceBalance_example
using RestSharp;

var client = new RestClient("https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance");
var request = new RestRequest(Method.POST);
request.AddHeader("X-API-KEY", "<apiKey>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"balance_at\": \"2026-10-09T12:00:00Z\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

**`Wallets_postWalletsByResourceIdOrReferenceBalance_example`**

```swift Wallets_postWalletsByResourceIdOrReferenceBalance_example
import Foundation

let headers = [
  "X-API-KEY": "<apiKey>",
  "Content-Type": "application/json"
]
let parameters = ["balance_at": "2026-10-09T12:00:00Z"] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://test.api.solvimon.com/v1/wallets/:resourceIdOrReference/balance")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

### Response (200)

```json
{
  "balance": {
    "credits": {
      "quantity": "string",
      "credit_type_id": "string",
      "credit_type": {
        "object_type": "string",
        "id": "string",
        "reference": "string",
        "status": "DRAFT",
        "name": "string",
        "description": "string",
        "unit_name": {
          "singular": "string",
          "plural": "string"
        },
        "created_at": "string",
        "updated_at": "string"
      }
    },
    "amount": {
      "quantity": "string",
      "currency": "AED"
    }
  },
  "reserved_balance": {
    "credits": {
      "quantity": "string",
      "credit_type_id": "string",
      "credit_type": {
        "object_type": "string",
        "id": "string",
        "reference": "string",
        "status": "DRAFT",
        "name": "string",
        "description": "string",
        "unit_name": {
          "singular": "string",
          "plural": "string"
        },
        "created_at": "string",
        "updated_at": "string"
      }
    },
    "amount": {
      "quantity": "string",
      "currency": "AED"
    }
  },
  "open_balance": {
    "credits": {
      "quantity": "string",
      "credit_type_id": "string",
      "credit_type": {
        "object_type": "string",
        "id": "string",
        "reference": "string",
        "status": "DRAFT",
        "name": "string",
        "description": "string",
        "unit_name": {
          "singular": "string",
          "plural": "string"
        },
        "created_at": "string",
        "updated_at": "string"
      }
    },
    "amount": {
      "quantity": "string",
      "currency": "AED"
    }
  },
  "balance_at": "string",
  "over_granted": {
    "credits": {
      "quantity": "string",
      "credit_type_id": "string",
      "credit_type": {
        "object_type": "string",
        "id": "string",
        "reference": "string",
        "status": "DRAFT",
        "name": "string",
        "description": "string",
        "unit_name": {
          "singular": "string",
          "plural": "string"
        },
        "created_at": "string",
        "updated_at": "string"
      }
    },
    "amount": {
      "quantity": "string",
      "currency": "AED"
    }
  },
  "customer_details": [
    {
      "customer_id": "string",
      "reserved_balance": {
        "credits": {
          "quantity": "string",
          "credit_type_id": "string",
          "credit_type": {
            "object_type": "string",
            "id": "string",
            "reference": "string",
            "status": "DRAFT",
            "name": "string",
            "description": "string",
            "unit_name": {
              "singular": "string",
              "plural": "string"
            },
            "created_at": "string",
            "updated_at": "string"
          }
        },
        "amount": {
          "quantity": "string",
          "currency": "AED"
        }
      },
      "balance": {
        "credits": {
          "quantity": "string",
          "credit_type_id": "string",
          "credit_type": {
            "object_type": "string",
            "id": "string",
            "reference": "string",
            "status": "DRAFT",
            "name": "string",
            "description": "string",
            "unit_name": {
              "singular": "string",
              "plural": "string"
            },
            "created_at": "string",
            "updated_at": "string"
          }
        },
        "amount": {
          "quantity": "string",
          "currency": "AED"
        }
      },
      "over_granted": {
        "credits": {
          "quantity": "string",
          "credit_type_id": "string",
          "credit_type": {
            "object_type": "string",
            "id": "string",
            "reference": "string",
            "status": "DRAFT",
            "name": "string",
            "description": "string",
            "unit_name": {
              "singular": "string",
              "plural": "string"
            },
            "created_at": "string",
            "updated_at": "string"
          }
        },
        "amount": {
          "quantity": "string",
          "currency": "AED"
        }
      }
    }
  ]
}
```

**A credit-amount wallet type's tax category is enforced**
A `CREDIT` wallet type funded with an amount carries a tax category and a default currency. The tax category was required at create time but only stored and returned: nothing read it, so it constrained nothing. It is now the declared spending scope of the wallet. Money funded under one tax category must not become spendable under another, so the tax category and the default currency can no longer be changed once wallets exist for that wallet type, and neither can be removed once set. Changing either on a wallet type that has no wallets yet is still allowed, and an attempt to change one under existing wallets is rejected with a validation error on `credit.amount_grant.tax_category` or `credit.amount_grant.default_currency`. See [Credit types and wallets](/platform-guides/wallets-credits/credit-types-wallets).

## Customers

**Seat assignments: record which customer occupies a seat**
`seats_values` on a pricing plan schedule records how many seats are paid for, but nothing recorded *which* child customer occupies one. A processing-only child was linked to its parent only through `parent_resource_ids`, so there was no way to exclude a child from being counted as a seat, to distinguish seat types, or to compare occupied seats against paid seats. Seat assignment is now an explicit resource at `/v1/seat-assignments`, with create, list, get, update and delete:

### Request

POST [https://test.api.solvimon.com/v\{version}/seat-assignments](https://test.api.solvimon.com/v\{version}/seat-assignments)

**`SeatAssignments_postSeatAssignments_example`**

```curl SeatAssignments_postSeatAssignments_example
curl -X POST https://test.api.solvimon.com/v1/seat-assignments \
     -H "X-API-KEY: <apiKey>" \
     -H "Content-Type: application/json" \
     -d '{
  "parent_customer_id": "cust_UwDeeN0vxxHxMTCJeF1K",
  "customer_id": "cust_FwDeeN0vxxHxN0COeY1H",
  "product_item_id": "proi_pwDeeN0vxxHxUUCxh81e",
  "start_at": "2026-10-01T00:00:00-05:00"
}'
```

**`SeatAssignments_postSeatAssignments_example`**

```python SeatAssignments_postSeatAssignments_example
import requests

url = "https://test.api.solvimon.com/v1/seat-assignments"

payload = {
    "parent_customer_id": "cust_UwDeeN0vxxHxMTCJeF1K",
    "customer_id": "cust_FwDeeN0vxxHxN0COeY1H",
    "product_item_id": "proi_pwDeeN0vxxHxUUCxh81e",
    "start_at": "2026-10-01T00:00:00-05:00"
}
headers = {
    "X-API-KEY": "<apiKey>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

**`SeatAssignments_postSeatAssignments_example`**

```javascript SeatAssignments_postSeatAssignments_example
const url = 'https://test.api.solvimon.com/v1/seat-assignments';
const options = {
  method: 'POST',
  headers: {'X-API-KEY': '<apiKey>', 'Content-Type': 'application/json'},
  body: '{"parent_customer_id":"cust_UwDeeN0vxxHxMTCJeF1K","customer_id":"cust_FwDeeN0vxxHxN0COeY1H","product_item_id":"proi_pwDeeN0vxxHxUUCxh81e","start_at":"2026-10-01T00:00:00-05:00"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

**`SeatAssignments_postSeatAssignments_example`**

```go SeatAssignments_postSeatAssignments_example
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://test.api.solvimon.com/v1/seat-assignments"

	payload := strings.NewReader("{\n  \"parent_customer_id\": \"cust_UwDeeN0vxxHxMTCJeF1K\",\n  \"customer_id\": \"cust_FwDeeN0vxxHxN0COeY1H\",\n  \"product_item_id\": \"proi_pwDeeN0vxxHxUUCxh81e\",\n  \"start_at\": \"2026-10-01T00:00:00-05:00\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("X-API-KEY", "<apiKey>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

**`SeatAssignments_postSeatAssignments_example`**

```ruby SeatAssignments_postSeatAssignments_example
require 'uri'
require 'net/http'

url = URI("https://test.api.solvimon.com/v1/seat-assignments")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["X-API-KEY"] = '<apiKey>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"parent_customer_id\": \"cust_UwDeeN0vxxHxMTCJeF1K\",\n  \"customer_id\": \"cust_FwDeeN0vxxHxN0COeY1H\",\n  \"product_item_id\": \"proi_pwDeeN0vxxHxUUCxh81e\",\n  \"start_at\": \"2026-10-01T00:00:00-05:00\"\n}"

response = http.request(request)
puts response.read_body
```

**`SeatAssignments_postSeatAssignments_example`**

```java SeatAssignments_postSeatAssignments_example
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://test.api.solvimon.com/v1/seat-assignments")
  .header("X-API-KEY", "<apiKey>")
  .header("Content-Type", "application/json")
  .body("{\n  \"parent_customer_id\": \"cust_UwDeeN0vxxHxMTCJeF1K\",\n  \"customer_id\": \"cust_FwDeeN0vxxHxN0COeY1H\",\n  \"product_item_id\": \"proi_pwDeeN0vxxHxUUCxh81e\",\n  \"start_at\": \"2026-10-01T00:00:00-05:00\"\n}")
  .asString();
```

**`SeatAssignments_postSeatAssignments_example`**

```php SeatAssignments_postSeatAssignments_example
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://test.api.solvimon.com/v1/seat-assignments', [
  'body' => '{
  "parent_customer_id": "cust_UwDeeN0vxxHxMTCJeF1K",
  "customer_id": "cust_FwDeeN0vxxHxN0COeY1H",
  "product_item_id": "proi_pwDeeN0vxxHxUUCxh81e",
  "start_at": "2026-10-01T00:00:00-05:00"
}',
  'headers' => [
    'Content-Type' => 'application/json',
    'X-API-KEY' => '<apiKey>',
  ],
]);

echo $response->getBody();
```

**`SeatAssignments_postSeatAssignments_example`**

```csharp SeatAssignments_postSeatAssignments_example
using RestSharp;

var client = new RestClient("https://test.api.solvimon.com/v1/seat-assignments");
var request = new RestRequest(Method.POST);
request.AddHeader("X-API-KEY", "<apiKey>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"parent_customer_id\": \"cust_UwDeeN0vxxHxMTCJeF1K\",\n  \"customer_id\": \"cust_FwDeeN0vxxHxN0COeY1H\",\n  \"product_item_id\": \"proi_pwDeeN0vxxHxUUCxh81e\",\n  \"start_at\": \"2026-10-01T00:00:00-05:00\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

**`SeatAssignments_postSeatAssignments_example`**

```swift SeatAssignments_postSeatAssignments_example
import Foundation

let headers = [
  "X-API-KEY": "<apiKey>",
  "Content-Type": "application/json"
]
let parameters = [
  "parent_customer_id": "cust_UwDeeN0vxxHxMTCJeF1K",
  "customer_id": "cust_FwDeeN0vxxHxN0COeY1H",
  "product_item_id": "proi_pwDeeN0vxxHxUUCxh81e",
  "start_at": "2026-10-01T00:00:00-05:00"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://test.api.solvimon.com/v1/seat-assignments")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

### Response (201)

```json
{
  "object_type": "string",
  "id": "string",
  "status": "SCHEDULED",
  "parent_customer_id": "string",
  "customer_id": "string",
  "product_item_id": "string",
  "start_at": "2024-01-15T09:30:00Z",
  "end_at": "2024-01-15T09:30:00Z",
  "created_at": "string",
  "updated_at": "string"
}
```

Occupancy is keyed on the `PER_SEAT` product item, which is the seat type, rather than on the schedule, because capacity itself is scoped per pricing item. `status` is derived at read time from the dates: `SCHEDULED` before `start_at`, `ACTIVE` while the seat is occupied, `ENDED` after `end_at`. Ending an assignment is a `PATCH` that sets `end_at`, which must be strictly after `start_at`; clearing `end_at` re-opens the seat. On its own this changes no billing behaviour: it is the record that makes seat-owned credits and occupied-versus-paid reporting possible. Four permissions govern it, `SEAT_ASSIGNMENT.VIEW`, `SEAT_ASSIGNMENT.CREATE`, `SEAT_ASSIGNMENT.UPDATE` and `SEAT_ASSIGNMENT.DELETE`, with the viewer role rolled into Read Only. See [Seat assignments](/platform-guides/customers-and-billing/customer/seat-assignments).

## Subscriptions

**Preview an invoice for a subscription that has only draft schedules**
A subscription whose schedules are all still [drafts](/platform-guides/customers-and-billing/subscriptions/draft-schedules) could not be previewed: with no active schedule there was nothing to price, so the preview refused rather than showing what the drafts would produce. The preview now includes draft schedules, which is what makes a draft subscription reviewable before anyone activates it.

**Draft schedules behave as one unit on a draft subscription**
While a subscription is itself a draft, every schedule on it is a draft too, so the per-schedule draft chip, the per-schedule activate action and the "Create as draft" toggles added noise around a decision that is not yours to make yet. On a draft subscription these are gone: schedules are created as drafts and all of them are activated together when you activate the subscription, from a single button. On an active subscription new schedules are still created as drafts, and the existing per-schedule controls are unchanged.

## Quotes

**Sales representatives can add schedules to a draft quote version**
Creating a pricing plan schedule required `PRICING_PLAN_SCHEDULE.CREATE`, which is the permission that governs changing live subscriptions. A sales representative building a quote therefore needed a permission far broader than the work in front of them. Schedule creation now checks which kind of subscription it targets: a quote subscription requires `QUOTE_PRICING_PLAN_SCHEDULE.CREATE` and is accepted only while the quote version is `DRAFT`, and anything else still requires `PRICING_PLAN_SCHEDULE.CREATE`. Adding a schedule to a quote version that has left draft is rejected with a validation error on `quote_version_id`, since a change to agreed terms belongs in the upgrade quote flow rather than in an edit.

**Custom fields carry over to a new quote version**
Creating a new version of a quote sends the custom fields of the version it came from. The link a CRM establishes through a custom field, an opportunity id in particular, survived into the subscription when a quote ran straight through, but was lost as soon as the quote was rejected or returned to draft and continued from a new version. The subscription created from that version now carries the link. See the [Salesforce quotes integration](/integrations/crm/salesforce/quotes).

## Invoices

**A new invoice PDF renderer, in preview**
Invoice PDFs can be produced by the same rendering service that already produces quote PDFs, which brings invoices onto one templating and localisation stack: the layout is built from the same component library as the rest of the product, and the invoice template ships translations for the languages the customer portal supports. It is opt-in while it is being rolled out. Contact your account manager to have it enabled for your platform.

**An auto rescue stops when the invoice is paid another way**
An invoice payment refused for insufficient funds schedules an automatic rescue with the payment provider. If the platform then settled the invoice through `POST /v1/invoices/{id}/pay`, the rescue still fired later and charged the customer a second time, leaving a refund to be issued. A successful invoice payment now cancels any pending auto rescue for that invoice, and a second auto rescue is not started for an invoice that already has one active. See [dunning and payment retries](/platform-guides/invoicing/dunning-and-payment-retries).

## Workflows

**Delete a draft workflow**
A workflow in `DRAFT` can be deleted, in line with the other draft resources: subscriptions, customers and pricing plans. A workflow that was created by mistake, or that was superseded before it ever ran, no longer has to be left in place. Deletion is governed by the new `WORKFLOW.DELETE` permission, and only draft workflows are deletable; an active one still has to be deactivated rather than removed, so no execution history disappears.

**Expand the trigger on an action execution**
`GET /v1/workflow-action-executions/{id}` and `POST /v1/workflow-action-executions/search` accept `expand[]=workflow_trigger_id`, which inlines the full `workflow_trigger` that produced the row. Building a list of executions with the resource each one acted on was a second call per row; it is now part of the search response. Successful email actions also record a more useful reason, naming the invoice and the message id the provider accepted, so a delivery can be traced from the [execution log](/platform-guides/workflows/monitoring-and-audit) into the provider without correlating timestamps.

**More template variables on the send-email action**
The native [send-email action](/platform-guides/workflows/workflow-actions) now passes `invoice_type`, `open_amount`, `customer_name`, `customer_type`, `credit_note` and `original_invoice_number` to the template alongside the variables it already sent. Templates written against the legacy Paragon-based Mailgun integration relied on these, and rendered them as empty values after the migration to native workflows. Migrated templates that use them render correctly without being rewritten.

**Action execution details in Desk**
A workflow action execution opens into a details panel showing what the action did, the trigger it came from and the outcome it recorded, rather than only the row in the executions table.

## Customer portal

### Fixes

bug-fix

* The Pay button is visible on the invoice list in the portal. It worked, but was not rendered, so paying from the list was only discoverable by accident; the overview page was unaffected.
* Embedded components receive your platform's primary colour as their brand colour, so an embedded balance or subscription view matches the surrounding page instead of falling back to a default palette.

## Desk

**Consistent metric selection on products**
The billing metric selector on a product item follows the same pattern as the rest of the side panel, and the meter filter, tiering metric and unit names moved into a context menu next to it. Choosing what a usage-based item measures looked different from every other field around it, and each of the related settings used its own control.

**The integration reference explained where it is used**
The reference on an integration is what makes a multi-account setup possible: several integrations of the same type on one platform, told apart by their reference. The dialog now explains what the reference is for and what to put in it, rather than presenting an unexplained field.