---
url: https://integrations.pocketarc.com/about/changelog.md
---
# Changelog

All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/).

## 6.5.1

* Fix: saving an integration no longer rewrites its encrypted `credentials` column when the credentials haven't changed. Encrypting the same credentials twice gives two different ciphertexts, and Eloquent compared the ciphertexts. If the provider's `credentialDataClass()` returns a Data class, reading `$integration->credentials` made the column count as changed, so every save after that read wrote it again. The package calls `recordSuccess()` to update the integration's health after every successful request, so the column was written on every request. The database locks the row for each of these writes, so they made lock wait timeouts more likely. The credential cast now decrypts both values and compares the decoded JSON, so assigning the same credentials is no longer a change either. Credentials that only a key in `APP_PREVIOUS_KEYS` can decrypt count as changed, so the first such save writes them again, encrypted with the current `APP_KEY`. Saves after that don't write the column.
* Fix: saving an integration no longer rewrites its `metadata` column when the metadata hasn't changed. Some databases, such as MySQL, change the whitespace and key order of the JSON stored in a JSON column, so the JSON read back can differ from the JSON written. If the provider's `metadataDataClass()` returns a Data class, reading `$integration->metadata` made the column count as changed, and every save after that read wrote it again. The metadata cast now compares the decoded JSON and ignores whitespace and object key order.

## 6.5.0

* Change: [`IntegrationTestCase`](/testing/testing#integrationtestcase) sets laravel-data's `validation_strategy` to `Always`. With laravel-data's default, `OnlyRequests`, `Data::from()` validates only when it hydrates a Data class from an HTTP request, so response Data classes were never validated in an adapter's tests. A consumer that sets `Always` validates every response, and a response that passed the adapter's tests could fail in production with a `SchemaDriftException`. For example, Zendesk sends an empty subject for a ticket created from an email with no subject. That ticket failed `ZendeskTicketData` validation, and the Zendesk incremental sync stopped at the page that contained it. A test that extends `IntegrationTestCase` now fails if its fixture has an empty string or an empty array in a non-nullable property of a Data class. Only tests use `IntegrationTestCase`, so this setting has no effect on production code. See the [upgrade guide](/about/upgrade-guide).
* Fix: [`integrations:test`](/reference/artisan-commands#integrations-test) counted an integration twice in its "Tested" total when the provider's health check threw an exception. Now the command counts that integration once.
* Docs: the [`IntegrationTestCase`](/testing/testing#integrationtestcase) section said that the class activates the fake in `setUp()` and provides `$this->integration`, but it does neither. The [`CreatesIntegration`](/testing/testing#createsintegration-trait) example called `createIntegration()` without the provider class, which is a required argument. Both sections are corrected.

## 6.4.0

`integrations:recover-webhooks` now dispatches a new job for a webhook stuck in `pending`, and it can retry a failed webhook.

This release adds an `attempts` column and a `(status, updated_at)` index to the webhooks table. Fresh installs get both from the baseline migration. For an existing deployment, add the downstream migration from the [upgrade guide](/about/upgrade-guide).

* Fix: [`integrations:recover-webhooks`](/reference/artisan-commands#integrations-recover-webhooks) dispatches a new job for a webhook that has been `pending` for longer than [`webhook.pending_timeout`](/reference/configuration#webhook), 1 hour by default. A webhook whose job was lost from the queue store stayed `pending` with no job to process it, and the command, which looked only for webhooks stuck in `processing`, never found it. If the first job is still queued, only one of the two jobs can claim the webhook, so the handler runs once. The command dispatches at most one extra job per webhook per timeout, even when two runs overlap.
* New: failed webhooks can be retried. `integration_webhooks.attempts` counts the times a job has claimed the webhook. With [`webhook.max_attempts`](/reference/configuration#webhook) above its default of 1, the command moves a failed webhook back to `pending` and dispatches a job once the [`webhook.retry_backoff`](/reference/configuration#webhook) wait for its last attempt has elapsed. It stops when the webhook reaches `max_attempts`. Retries are off by default, because a retry runs the handler again from the start. Before this release, a failed webhook stayed failed until someone ran [`integrations:replay-webhook`](/reference/artisan-commands#integrations-replay-webhook) on it: a queue retry of `ProcessWebhook` does nothing, because the job claims only a `pending` webhook. A webhook that failed before this release has 0 attempts, and the command never retries it. See [recovering stranded webhooks](/features/webhooks#recovering-stranded-webhooks).
* Fix: `ProcessWebhook` claims a webhook before it checks the webhook's integration and provider. It used to check them first, so a late duplicate job for a webhook that another job had already processed could mark that webhook `failed` when its provider no longer implemented `HandlesWebhooks`. The claim increments `attempts`, so a failure at either check is now counted as an attempt, and a webhook that keeps failing there stops being retried at `webhook.max_attempts`.
* Fix: a job that runs past [`webhook.processing_timeout`](/reference/configuration#webhook) no longer overwrites the result of the job dispatched to replace it. [`integrations:recover-webhooks`](/reference/artisan-commands#integrations-recover-webhooks) resets such a webhook to `pending` and dispatches a new job, but the first job could still be running, and when it finished, it wrote its result over whatever the new job had recorded. After `markProcessing()` claims a webhook, `markProcessed()` and `markFailed()` on the same model instance update it only while it is still `processing` at the claimed attempt. Without a successful `markProcessing()` on the same instance, they update the webhook unconditionally, as before. `markProcessing()` now claims a webhook only if `attempts` still holds the value the instance loaded, so an instance loaded before another job's claim cannot claim the same attempt after a reset.
* The recommended schedule for the command is now every five minutes instead of hourly, because a failed webhook is retried only when the command runs.
* Fix: the retry handler reads `Retry-After` from exceptions whose `getResponse()` method returns a PSR-7 response, such as an SDK's own exception or a Guzzle 8 `ResponseException` that isn't a `BadResponseException`. It checks the thrown exception first, then its previous exceptions in order (`getPrevious()`), and reads the header from the first response it finds. In 6.3.0, it read the header only from Guzzle's `BadResponseException`, so for any other exception it used its [default delay](/core-concepts/retries#backoff-strategy). See [SDK exception support](/core-concepts/retries#sdk-exception-support).
* Fix: the retry handler reads `Retry-After` from `Illuminate\Http\Client\RequestException`. Before this release, it did not read the header from that exception, so it used its default delay: 30 seconds after a 429, or 2 seconds times the attempt number after a 5xx.
* Fix: a request whose upstream call succeeded no longer fails when `json_encode` can't encode the response for storage. `json_encode` throws on values such as `INF`, `NAN`, and invalid UTF-8. A callback can return one of these. For redaction, the package decodes the body and encodes it again, and PHP decodes a number too large for a float, such as `1e999`, to `INF`. Previously, when a callback's return value couldn't be encoded, the request was recorded as a failure and counted against the circuit breaker and the integration's health. When a redacted body couldn't be encoded again, the caller got a `JsonException` and no request row was stored. Now the caller gets the callback's return value unchanged, and the package stores an `[UNENCODABLE <exception message>]` marker in place of the whole body, so redacted fields still never reach the database. The response isn't cached, even with `cacheFor`. When a request body couldn't be encoded again after redaction, the caller got a `JsonException` before the upstream call was made. Now the package makes the call and stores the marker as the request data. It doesn't store the response in the cache or look for a cached one. The cache key is a hash of the request data. If `json_encode` throws the same error for two different request bodies, both are replaced by the same marker, and a caller could otherwise be served the cached response to a different request. See [data redaction](/features/redaction).
* Fix: a request whose body contains a field from the provider's `sensitiveRequestFields()` is no longer cached or served from the cache. Before the package calculates the cache key's hash, it replaces the value of each of these fields with `[REDACTED]`. Two requests that differed only in a redacted field, such as a password, had the same cache key. The second caller got the first caller's cached response. `cacheFor` and `serveStale` now have no effect on these requests. See [requests that are never cached](/core-concepts/response-caching#requests-that-are-never-cached).
* Fix: when the package redacts a body, it replaces only the configured fields that are present. It used to add a `[REDACTED]` field for each configured path that the body did not contain. It also decoded and encoded every JSON body again, so a stored body could differ from the original even when nothing was redacted. Now, if a body contains none of the configured fields, the package stores it unchanged, byte for byte. See [data redaction](/features/redaction).
* Fix: a request with a binary body can now be served from the cache. The package stores a binary request body as a `[BINARY <length> bytes sha256=<hash>]` marker. The stored hash was taken from the marker, but the lookup hash was taken from the raw bytes, so the two never matched. The package now calculates both hashes from the raw bytes. A text body that is identical to a binary body's marker no longer has the same cache key as the binary request. See [how response caching works](/core-concepts/response-caching#how-it-works).
* Fix: a response whose body contains a field from the provider's `sensitiveResponseFields()` is no longer cached. The stored copy of the response has `[REDACTED]` in place of each secret, such as a token, and a caller served from the cache got that copy. See [requests that are never cached](/core-concepts/response-caching#requests-that-are-never-cached).
* Fix: two request bodies that differ only after their first 65530 bytes now have different cache keys. The package stores only the first 65530 bytes of a request body, and it took the cache key's hash from that truncated copy. The second caller got the first caller's cached response. The package now hashes the whole body. The cache key's hash also has a new format, so the package doesn't serve cache entries stored before the upgrade. On the next matching request, the package calls the upstream again and caches the new response. See [how response caching works](/core-concepts/response-caching#how-it-works).

## 6.3.0

* Fix: [`authenticatedUser(cacheFor: ...)`](/features/authenticated-identity#caching) no longer throws a `TypeError` on a cache hit when `cache.serializable_classes` is `false`, a setting under which cached objects can't be unserialized. The identity is now cached as JSON under a new key. During a deploy, workers still on an older release read only the old key, so they never read an entry in the new format. A malformed cached entry counts as a cache miss, and the identity is fetched again and written over it. On a cache hit, `authenticatedUser()` now builds a plain `AuthenticatedUser` from the cached fields, so any property that a provider's subclass adds is lost. Each object in `raw` is returned as the array or scalar that `json_decode()` produces from its JSON. On a cache miss, `authenticatedUser()` still returns the provider's own object.
* Fix: now that the identity is cached as JSON, `authenticatedUser(cacheFor: ...)` throws `UncacheableAuthenticatedUser` and caches nothing if the provider returns an identity that isn't JSON-encodable or whose `raw` has an integer top-level key. PHP converts a numeric string key such as `"123"` to an integer when it decodes JSON, so if `raw` is decoded from an upstream object with a numeric top-level key, the identity can't be cached. A call without `cacheFor` is unaffected. See [implementing it on a provider](/features/authenticated-identity#implementing-it-on-a-provider).
* Fix: the cached identity is now cleared when the OAuth callback or revoke route runs. Previously, the cached entry held the old account until it expired, and both `authenticatedUser(cacheFor: ...)` and [`integrations:health`](/reference/artisan-commands#integrations-health) read from it. With the one-day TTL from the [caching example](/features/authenticated-identity#caching), that meant up to a day.
* Fix: the package works with Guzzle 8, which Composer can install alongside Laravel 13.33.0. In Guzzle 8, `RequestException` has no `getResponse()` method. If an exception chain contained a `RequestException` without a response, the package threw an `Error` when it called that method to read the status code or the `Retry-After` header. The package now reads the response from `BadResponseException`, which Guzzle throws for 4xx and 5xx responses in both Guzzle 7 and 8. See [SDK exception support](/core-concepts/retries#sdk-exception-support).
* New: `Integration::forgetAuthenticatedUser()` clears the cached identity. Call it after changing credentials outside the OAuth routes. See [credential changes](/features/authenticated-identity#credential-changes).

## 6.2.0

A sync run always reaches a terminal state, and the schedule always moves afterwards. Everything below closes a case where one or the other did not hold.

This release adds `consecutive_sync_failures` and `sync_stale_alerted_at` to the integrations table. Fresh installs get both from the baseline migration. For an existing deployment, add the downstream migration from the [upgrade guide](/about/upgrade-guide).

* Fix: sync items abandoned in flight no longer block every future run. A queue worker that dies mid-batch leaves its rows at `pending`/`processing` with no job behind them, and `SyncIntegration`'s preflight check finds them on every later run. No command cleared such rows: `cleanupOrphanedItems()` matched only a null `batch_id`, [`integrations:skip-sync-item`](/reference/artisan-commands#integrations-skip-sync-item) accepts only `failed`, and [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor) does nothing while items are non-terminal. Rows in flight past [`sync.item_reclaim_after`](/reference/configuration#sync) are now marked `failed` and their run re-finalised, so the run log closes, the cursor stays put, and the next run re-enumerates the work. The threshold cannot be `sync.job_timeout`, because a rate-limited item is released back onto the queue and sits in flight for hours by design. It is never applied below `item_retry_window` plus an hour. See [abandoned items](/features/scheduled-syncs#abandoned-items).
* Fix: abandoned rows are marked `failed` rather than deleted, including when the batch was never dispatched. Deleting every row of a run strands its log at `processing`, because `FinaliseSyncRun` stops early on an empty item set.
* New: `--reclaim-stale` on [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor) marks abandoned rows `failed` and reconciles their runs at once, rather than waiting out `sync.item_reclaim_after`.
* Fix: `next_sync_at` advances on every finalised run, and `last_synced_at` only on a clean one. `markSynced()` ran only when a run had no failures, so a single permanently-failing item froze both timestamps. `dueForSync()` matches `next_sync_at <= now()`, so the integration stayed due on every scheduler tick and re-ran the same failing sync against the provider's API. A new `consecutive_sync_failures` column sets a doubling multiplier on the interval, capped by [`sync.failure_backoff_max_multiplier`](/reference/configuration#sync). The first failure keeps the plain interval, so an isolated bad run costs nothing. Separating the two timestamps also makes staleness detectable. See [when runs keep failing](/features/scheduled-syncs#failure-backoff).
* Fix: the per-run bookkeeping in `FinaliseSyncRun` happens exactly once. The job has four dispatch sites by design and retries ten times, and `markSynced()` sat outside the once-only guard, so each re-finalisation fired `IntegrationSynced` and moved the sync timestamps again.
* New: [sync staleness](/core-concepts/health-monitoring#sync-staleness) as a signal in its own right. `consecutive_failures` counts API-boundary faults and resets on any success, so a sync that fetches successfully and then fails while processing resets it every pass, and the integration counts as healthy for as long as it stays stuck. `isSyncStale()` and `syncStaleness()` derive the signal, [`integrations:health`](/reference/artisan-commands#integrations-health) and [`integrations:list`](/reference/artisan-commands#integrations-list) flag it, and [`integrations:sync`](/reference/artisan-commands#integrations-sync) emits [`SyncBecameStale`](/reference/events#syncbecamestale) once per episode, with [`SyncStalenessRecovered`](/reference/events#syncstalenessrecovered) when a clean sync clears it. Staleness is deliberately not a `HealthStatus`: `recordSuccess()` sets that to `Healthy` on every successful request, so a degradation written there would be overwritten within seconds.
* Fix: an incident closes only once every signal is clear, rather than on health alone, and [`integrations:prune`](/reference/artisan-commands#integrations-prune) no longer auto-closes one for an integration that is healthy but sync-stale. Both previously closed an incident whose condition was still current. Incidents record `sync` as a `source` alongside `health` and `circuit`.
* New: [`SyncItemStuck`](/reference/events#syncitemstuck) carries the single record a sync cannot advance past, rather than the run that failed on it. The row's `attempts` column cannot record the streak, because each run inserts fresh rows and the counter never accumulates. The streak is counted across rows that share an external ID, bounded to the IDs that failed in the run just finalised, and reset by any later success. Threshold: [`sync.stuck_item_after_runs`](/reference/configuration#sync).
* Fix: a provider that emits the same external ID twice in one run no longer produces two jobs for it. A provider paging on an inclusive cursor boundary emits the record on that boundary twice. Both copies previously ran at the same time on different workers and wrote the same record. `SyncSession::dispatch()` keeps the first copy and records the number dropped on the run's log. See [duplicate items](/features/scheduled-syncs#duplicate-items).
* Fix: the "previous batch still in flight" log line is a warning rather than info, and includes how long the batch has been in flight. It was logged at info level and worded the same as the line for an ordinary slow run.

## 6.1.0

* Fix: [`integrations:find-orphans`](/reference/artisan-commands#integrations-find-orphans) selects only the id and created-at columns instead of whole rows. It read whole rows before, so on a model that stores payloads inline the command loaded every byte of every 1000-row chunk. One consumer exhausted a 512MB memory limit on a table of file attachments before the command printed anything.
* Fix: three more commands select only the columns they use. [`integrations:recover-webhooks`](/reference/artisan-commands#integrations-recover-webhooks) loaded every stale webhook's `payload` and `headers` to reset a status by id. That command runs when the queue is backed up, so its scan covers the largest payloads. [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor) loaded each sync log's `metadata`, `result_data` and `error` to dispatch a job by id. [`integrations:list-failed-items`](/reference/artisan-commands#integrations-list-failed-items) loaded `checkpoint_value`, which its table never shows.
* New: `--limit` on [`integrations:list-failed-items`](/reference/artisan-commands#integrations-list-failed-items) (default 50) and [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor) (no default). Both read every matching row before. The listing commands print a notice when they stop at the limit, so a truncated list is not mistaken for the whole set. `integrations:advance-cursor` has no default limit, because a silent cap there would leave sync runs unreconciled.

## 6.0.0

One external ID maps to exactly one local row per integration and model type, and the package now enforces that rather than assuming it. See the [upgrade guide](/about/upgrade-guide) for the migration.

* Breaking: [`mapExternalId()`](/features/id-mapping#claiming-and-re-pointing) throws `MappingAlreadyClaimed` when a different model of the same type already holds the external ID on that integration, instead of silently re-pointing the mapping at the new one. The silent version made a lost race unobservable: two workers upserting the same external ID each inserted a local row, the second took the mapping, and the first was left intact in every column but unaddressable upstream. The [upgrade guide](/about/upgrade-guide) has what that cost one consumer. Calling it twice with the same model is still a no-op.
* New: `remapExternalId()` moves a mapping deliberately, which is what `mapExternalId()` used to do by accident. The displaced model keeps its row and loses its external ID; reconciling it is the caller's job.
* Fix: [`upsertByExternalId()`](/features/id-mapping#upsert-by-external-id) is serialised per `(integration, model type, external ID)` with a cache lock, and converges on the winner's row if a claim collides anyway. The lock only serialises across processes on a shared cache driver. TTL and wait are configurable under [`mappings`](/reference/configuration).
* New: [`integrations:find-orphans`](/reference/artisan-commands#integrations-find-orphans) lists rows of a mapped model that have no external ID, for finding rows that lost their mapping before 6.0. It compares keys in PHP rather than joining, so it works regardless of the collation on `internal_id`.
* New: `integrations.mappings.collation` pins the mapping table's string columns on MySQL and MariaDB. `internal_id` is a VARCHAR holding keys of every type, so comparing it against your own primary keys fails with `Illegal mix of collations` when your tables use a different collation. Null (the default) inherits the connection default. A second migration applies it to existing installs.
* Breaking: [`IntegrationCreated`](/reference/events#integrationcreated) now implements `ShouldDispatchAfterCommit`. Eloquent's `created` hook fires on insert, not on commit, so a listener running inside an open transaction could act on an integration that never persisted. `upsertByExternalId()` rolls back exactly that insert whenever it loses the mapping claim. A listener that has to run before the commit, to write into the same transaction, needs a model hook instead.
* The ID-mapping methods moved from `Integration` onto an `Integrations\Concerns\MapsExternalIds` trait, and key stringification onto `Integrations\Support\ModelKey`. The public API on `Integration` is unchanged.

## 5.5.0

* New: two ways to stop response bodies dominating `integration_requests`, which in a busy installation is reliably the largest table the package owns. Request bodies were already cut at just under 64KB; response bodies were stored whole, so one verbose endpoint could account for most of the table. [`logging.max_response_bytes`](/reference/configuration#logging) caps a stored body and records the size it was cut from, and the new [`LimitsRequestLogging`](/reference/contracts#limitsrequestlogging) contract lets a provider name endpoint patterns whose bodies are not stored at all, for payloads that are enormous or already persisted better elsewhere. Both leave the request row itself intact, so health, failure rates and the stats commands are unchanged. Defaults keep current behaviour: no cap, nothing opted out.
* Bodies the package reads back are exempt from both the cap and the contract. A response being cached is the cache's payload, and a response to an idempotent write backs [`IdempotencyConflict`](/core-concepts/idempotency#recovering-on-conflict) recovery, so truncating or dropping either would turn a logging setting into a correctness bug.
* Endpoint patterns now come from one implementation, `Support\EndpointPattern`, shared by the testing fake's expectations and the new contract. A pattern written against a fake keeps its meaning in provider configuration.

## 5.4.0

* Fix: `FinaliseSyncRun` now dispatches onto the same queue as the run's `ProcessSyncItem` jobs instead of the default queue. Reconciliation is the bookkeeping the cursor depends on, and on the default queue it competed with whatever else a consumer runs there; a backed-up default queue could leave runs unfinalised indefinitely while their items succeeded. On the item queue, finalisation drains with the batch it belongs to and honours per-provider [`sync.queues`](/reference/configuration#sync) routing. The new `FinaliseSyncRun::dispatchFor()` is the single dispatch path used by the batch's `finally` callback, the retry catch-up in `ProcessSyncItem`, [`integrations:skip-sync-item`](/reference/artisan-commands#integrations-skip-sync-item), and [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor).

## 5.3.1

* Fix: [`failureSummary()`](/core-concepts/health-monitoring#failure-summary) no longer trips PHP 8.4+'s "Using null as an array offset is deprecated" warning. `FailureReporter`'s per-class and per-status breakdowns grouped on a nullable column and fed the result to `pluck('count', <column>)`, so a failed request with a null `failure_class` (a row written before that column existed) or a null `response_code` (a connection error or timeout, which carries no HTTP status) built a null array key. They now iterate the grouped rows, folding a null `failure_class` into `unknown` and a null `response_code` into `other` as before. The call was silent on PHP 8.2/8.3; on 8.4/8.5 it fired on routine summary calls, and a consumer that promotes deprecations to exceptions saw the call abort rather than warn. The test suite now sets `failOnDeprecation` in `phpunit.xml`, so the PHP 8.4/8.5 CI matrix catches this class of regression.

## 5.3.0

A failure-observability layer on top of the existing resilience machinery: a way to alert on terminal failures only, a failure-summary API, a per-incident anomaly signal, and a durable incident history. The schema changes (`integration_requests.failure_class`, `integration_logs.attempt` / `max_attempts`, `integrations.anomaly_alerted_at`, and the new `integration_incidents` table) land in the canonical migration, so fresh installs get them on first migrate. For an existing deployment, add the downstream migration from the [upgrade guide](/about/upgrade-guide).

* New: [terminal-vs-transient failure semantics](/core-concepts/logging#terminal-vs-transient-failures). A listener that logs `failed` inside a [sync item](/features/scheduled-syncs) re-fires [`OperationFailed`](/reference/events#operationfailed) on every retry, even when the item later succeeds — the bulk of the alert noise, and not fixable in the consumer because the retry state lives in `ProcessSyncItem`. The job now exposes it: a `SyncAttemptContext` (plain `final readonly`) set around the per-item event and readable via `Integration::currentSyncAttempt()`, mirroring the existing `currentContext()` escape hatch. `logOperation()` stamps the attempt onto the new `integration_logs.attempt` / `max_attempts` columns and onto `OperationFailed` (a nullable third constructor argument — existing listeners are unaffected). Alert on terminal failures only by moving the forward to [`SyncItemFailed`](/reference/events#syncitemfailed), which still fires exactly once on exhaustion; `SyncAttemptContext::isLikelyFinalAttempt()` is a best-effort filter for operation-granularity `OperationFailed` hooks, never a substitute for it.
* New: a [failure-summary API](/core-concepts/health-monitoring#failure-summary). `Integration::failureSummary(CarbonInterface $since)` returns a `FailureSummary` (plain `final readonly`) with per-operation counts, distinct-item counts, failure rate, last error, and per-status / per-`FailureClass` breakdowns, computed from `integration_requests` and `integration_logs`. The [`integrations:health`](/reference/artisan-commands#integrations-health) and [`integrations:stats`](/reference/artisan-commands#integrations-stats) commands now render this summary, so the CLI and consumers report the same numbers. New `since()` request/log scopes back it.
* New: `integration_requests.failure_class` is now [persisted](/reference/database-schema#integration-requests) (set at execution time from the same [`FailureClassifier`](/advanced/circuit-breaker#what-counts-as-a-failure) verdict the breaker and health tracking use) so a per-class breakdown is queryable without re-classifying. New `withFailureClass()` scope and a `(integration_id, failure_class, created_at)` index.
* New: an [anomaly signal](/advanced/circuit-breaker#anomaly-signal) for alerting. [`integrations:evaluate-failures`](/reference/artisan-commands#integrations-evaluate-failures) measures each active integration's failure rate over a rolling window and dispatches [`ElevatedFailureRate`](/reference/events#elevatedfailurerate) once per incident — not one per failure — plus [`FailureRateRecovered`](/reference/events#failureraterecovered) when it clears. The open/closed state is a durable column (`integrations.anomaly_alerted_at`), so a cache flush can't drop a pending recovery. Schedule the command yourself; thresholds live in the new [`observability`](/reference/configuration#observability) config block, and where alerts go stays in the consumer. `CircuitBreaker::inspect()` also gains a `failure_rate` field exposing the breaker's live window rate.
* New: a durable [incident history](/core-concepts/health-monitoring#incident-history). The package records an `integration_incidents` audit row from its own `IntegrationHealthChanged` / `CircuitOpened` / `CircuitClosed` / `IntegrationDisabled` events — one open incident per integration into which both health and circuit signals fold (tracking peak severity), closed on recovery. Unlike the cache-only circuit state, this survives a `cache:clear`, so "incidents since T" is answerable. Read it with the `incidents()` relation and the `current_incident` / `has_open_incident` accessors; [`integrations:prune`](/reference/artisan-commands#integrations-prune) sweeps closed incidents ([`pruning.incidents_days`](/reference/configuration#pruning), default 365) and auto-closes stale-open ones for healthy integrations. Toggle with `observability.incidents_enabled`.
* New: documented `IntegrationLog::STATUS_*` constants (`success`, `failed`, `processing`, `partial`, `deferred`) for the `status` field. The column stays a free string with the same event-dispatch behaviour; the constants are the documented vocabulary, nothing more.

## 5.2.0

* New: [provider-scoped passthrough](/testing/testing#provider-passthrough) on the testing fake. `IntegrationRequest::fake([...])->passthrough('openrouter')` lets the named provider's requests fall through to the real request executor instead of being served from the fake -- for when that provider is faked at a layer underneath `Integration::request()` (e.g. an AI call routed through the breaker and retries but stubbed at the SDK). Other providers stay faked. Opt-in and idempotent. Passthrough requests run for real and aren't recorded by default, so they don't appear in `assertRequested()`; add `recordPassthrough()` to log them for the assertions anyway. Unmatched requests for non-passthrough providers still return `null`.

## 5.1.0

* New: the [`DeclaresRateLimit`](/reference/contracts#declaresratelimit) provider contract carries `defaultRateLimit()` on its own, so a request-only provider can ship an in-code rate budget without implementing [`HasScheduledSync`](/reference/contracts#hasscheduledsync). The method moved up from `HasScheduledSync`, which now extends `DeclaresRateLimit`, so existing sync providers satisfy the new contract unchanged. `Integration::effectiveRateLimit()` reads any `DeclaresRateLimit` provider, and a [runtime override](/advanced/circuit-breaker#runtime-overrides) still takes precedence over the declared default. `make:integration-provider` gains a `--rate-limit` flag to scaffold a request-only provider's limit.
* New: the [`IdentifiesAuthenticatedUser`](/reference/contracts#identifiesauthenticateduser) provider contract resolves which account an integration's credentials authenticate as, the principal behind the token, mapped to a provider-agnostic [`AuthenticatedUser`](/features/authenticated-identity). Read it through `Integration::authenticatedUser()`, which makes the upstream "who am I" call through the request executor (so the breaker, rate limiter, and logging apply), caches the result with an optional `cacheFor` and `refresh`, and throws [`UnsupportedByProvider`](/features/authenticated-identity#reading-the-identity) when the provider doesn't implement the contract (pre-check with `supportsAuthenticatedUser()`). [`integrations:health`](/reference/artisan-commands#integrations-health) shows the resolved identity for providers that support it.

## 5.0.0

The circuit breaker is now an availability detector driven by a single failure classifier, and it can be controlled at runtime without a redeploy. See the [upgrade guide](/about/upgrade-guide) for the migration.

* Breaking: one [`FailureClassifier`](/advanced/circuit-breaker#what-counts-as-a-failure) decides what a failure means, and the same verdict feeds both the breaker and [health tracking](/core-concepts/health-monitoring). Only upstream faults (5xx except 501, connection errors, timeouts) count. HTTP 429 and other 4xx client errors no longer trip the breaker or degrade health: a throttle is the rate limiter's concern, and a malformed request from one caller can't pull an integration offline for everyone sharing it.
* Breaking: the breaker now defaults to a [rate strategy](/advanced/circuit-breaker#strategies) (failure percentage over a window) rather than a consecutive count. The [config block](/reference/configuration#circuit-breaker) gains `strategy`, `time_window`, `failure_rate_threshold`, and `minimum_requests`. Set `strategy` to `'count'` for the previous consecutive-failure behaviour, which is also the volume-independent choice for low-traffic integrations.
* Breaking: `Integration::recordFailure()` now takes a `FailureClass` argument.
* New: the [`ClassifiesFailures`](/reference/contracts#classifiesfailures) provider contract maps an SDK's exceptions to a failure class. Core also duck-types the common SDK status accessors (`getStatusCode()`, `getHttpStatus()`, `getHttpStatusCode()`, `getCode()` when it's a valid HTTP status, a wrapped PSR-7 response), so most SDKs classify correctly without it.
* New: [runtime overrides](/advanced/circuit-breaker#runtime-overrides) that force a circuit open, closed, or disabled, and override a rate limit, with optional expiry, via model helpers (`forceCircuitOpen()`, `overrideRateLimit()`, …) or the [`integrations:circuit`](/reference/artisan-commands#integrations-circuit) and [`integrations:rate-limit`](/reference/artisan-commands#integrations-rate-limit) commands. Backed by new columns on the integrations table, so they survive a `cache:clear`. Toggle globally with `circuit_breaker.overrides_enabled` / `rate_limiting.overrides_enabled`.
* New: [`CircuitOpened`](/reference/events#circuit-breaker) and [`CircuitClosed`](/reference/events#circuit-breaker) events fire on every transition (automatic or forced) with a reason, and a publishable `SendCircuitNotification` listener turns them into notifications. `integrations:health` and `integrations:list` now show breaker state.

## 4.2.0

* [`IdempotencyConflict`](/core-concepts/idempotency#recovering-on-conflict) now carries `$e->priorState` (an [`IdempotencyPriorState`](/core-concepts/idempotency#recovering-on-conflict) case: `NoRow`, `EmptyBody`, `Unparseable`, or `Recovered`) and `$e->priorRowId` alongside the existing `$e->priorResponse`. Catch blocks can now distinguish "no prior request on file" from "row exists but empty body" from "row exists but corrupt JSON", three failure modes that 4.1 collapsed into "priorResponse is null". Backward compatible: the new constructor arguments are added after `$priorResponse` with safe defaults, so existing positional callers keep working unchanged.
* New [`Integration::getIdempotencyRecovery(string $key): IdempotencyRecovery`](/core-concepts/idempotency#recovering-on-conflict) method that returns the same `(priorState, priorRowId, priorResponse)` shape attached to the exception. `getIdempotencyResponse()` from 4.1 becomes a thin wrapper that returns just the decoded array for callers that don't care about the state distinction.

## 4.1.0

* [`IdempotencyConflict`](/core-concepts/idempotency#recovering-on-conflict) now carries `$e->priorResponse`, the decoded JSON body of the prior successful keyed call for the same key. The catch block can replay it directly instead of re-fetching from upstream or querying `integration_requests` by hand. `null` when nothing recoverable is on file (no prior request row, the prior was logged as failed, `response_data` is null, or the persisted JSON is unparseable). The lookup runs only on the conflict path, with no overhead on the success path. The new constructor argument is added after `$previous`, so existing positional callers (`new IdempotencyConflict($id, $key, $e)`) keep working unchanged.
* New [`Integration::getIdempotencyResponse(string $key): ?array`](/core-concepts/idempotency#recovering-on-conflict) method that backs the exception attribute. Useful when you want to probe for a prior response outside the catch flow, or recover from a key the exception isn't carrying for you. Returns the same shape (`null` when no recoverable prior is on file, the decoded response array otherwise) and scopes to the integration it's called on.

## 4.0.0

Rate limits are now window-aware, and a rate-limited sync item is deferred rather than failed. The GitHub adapter had declared `60` (GitHub's unauthenticated, per-hour figure) in a field the framework read as requests per minute, and when the limiter gave up waiting it threw an exception that failed the `ProcessSyncItem` job and wedged the sync. See the [upgrade guide](/about/upgrade-guide) for the migration.

* Breaking: [`HasScheduledSync::defaultRateLimit()`](/reference/contracts#hasscheduledsync) returns `?Integrations\RateLimit` instead of `?int`. A [`RateLimit`](/core-concepts/rate-limiting) carries the request count, the window in seconds, and a fixed/sliding strategy. Build one with `RateLimit::perHour(5000)`, `RateLimit::perMinute(700)`, `RateLimit::perDay(...)`, or `RateLimit::per($limit, $seconds)`; append `->sliding()` for an upstream that enforces a rolling window. `null` still means unlimited.
* Breaking: `RateLimitExceededException`'s constructor changed. It now carries `retryAfterSeconds` (when capacity is next expected) and an optional `RateLimit`, replacing the old `requestsThisMinute` / `limit` pair.
* The [`RateLimiter`](/core-concepts/rate-limiting#fixed-vs-sliding-windows) enforces a fixed window by default, so a provider may spend its whole budget in a burst, the way a quota like GitHub's hourly limit behaves. Declare the limit `->sliding()` for a rolling window instead. The previous implementation always approximated a sliding minute.
* Inside a sync, hitting the rate limit now [defers the item](/core-concepts/rate-limiting#rate-limits-and-syncs): `ProcessSyncItem` catches `RateLimitExceededException` and releases the job with the limiter's retry-after delay, so the run stays in flight. Previously the exception failed the item and stalled the cursor.
* [`sync.item_tries`](/reference/configuration#sync) now bounds genuine listener exceptions only; transient rate-limit deferrals no longer count against it. New `sync.item_retry_window` config (default 6h) is the absolute bound on how long an item may keep deferring.

## 3.0.0

Sync now tracks per-item completion. Cursor advancement waits for the items' listeners to finish, instead of moving on as soon as the events were dispatched. This closes a silent-data-loss gap: previously a queued listener that exhausted its retries left the item in `failed_jobs` while the cursor had already advanced past it, and once the item fell outside the overlap window it was never re-fetched. See the [upgrade guide](/about/upgrade-guide) for the migration.

* Breaking: `HasScheduledSync::sync()` and `HasIncrementalSync::syncIncremental()` no longer return a `SyncResult`. They now take a [`SyncSession`](/reference/contracts#hasscheduledsync) as a second argument and return `void`. The provider enumerates items and hands each to `$session->dispatch($event, $checkpointValue, $externalId)` instead of dispatching events itself. `HasScheduledSync` also gains `reduceCheckpoints(array): mixed`; implement it directly, or `use Integrations\Concerns\ReducesCheckpointsByMax` for the common "max wins" reduction. Read the previous cursor with `$session->cursor()`; providers no longer write `sync_cursor` themselves.
* Breaking: events handed to `$session->dispatch()` must extend `Integrations\Sync\SyncItemEvent`, and their listeners must not implement `ShouldQueue`. The framework's new `ProcessSyncItem` job is the queued unit, and it invokes listeners synchronously so the job's success reflects the listener's. A queued listener fails the item with `SyncListenerMustNotBeQueuedException`. Listeners that need async follow-up work should dispatch their own job.
* Breaking: `SyncResult` is now `@internal`. The framework constructs it from a run's `integration_sync_items` rows and carries it on the new `SyncCompleted` event; adapters no longer build or return it.
* New `integration_sync_items` table: one row per dispatched item, tracking `pending` / `processing` / `success` / `failed` / `skipped`. Requires running migrations. The sync flow also dispatches a `Bus::batch`, so Laravel's `job_batches` table must exist (`php artisan queue:batches-table`).
* New canonical sync events. [`SyncCompleted`](/reference/events#sync) fires once a run reconciles (carrying a `SyncResult`); [`SyncItemFailed`](/reference/events#sync) fires when an item exhausts its retries. These replace the per-adapter aggregate and failure events.
* New recovery commands. [`integrations:list-failed-items`](/reference/artisan-commands#integrations-list-failed-items) surfaces items needing attention, [`integrations:skip-sync-item`](/reference/artisan-commands#integrations-skip-sync-item) skips an unrecoverable one so the cursor can move on, and [`integrations:advance-cursor`](/reference/artisan-commands#integrations-advance-cursor) re-reconciles a stuck run. `integrations:prune` now also prunes completed sync items ([`pruning.sync_items_days`](/reference/configuration#pruning), default 30).
* New config under [`integrations.sync`](/reference/configuration#sync): `item_queue`, `item_tries`, `item_backoff`, and `max_items_per_batch`.

## 2.5.1

* `RequestExecutor::persistRequest()` now sanitizes non-UTF-8 byte sequences in both `request_data` and `response_data` before insert, replacing them with a `[BINARY <length> bytes sha256=<hash>]` marker. Previously, adapter resources that returned raw bytes (Zendesk `attachments()->download()`, GitHub `assets()->download()`, anything else handing `Http::...->body()` straight through a closure) crashed the INSERT with `SQLSTATE[22007] ... Incorrect string value`, because the columns are `longText` (utf8mb4) and MariaDB/MySQL reject bytes that don't decode as UTF-8. The audit row is still written with the marker for diagnostics. `expires_at` is nulled out on binary responses so the row never becomes a cache source. New `Integrations\Support\BinaryGuard` helper exposes the check. No schema change.

## 2.5.0

* `SyncIntegration::middleware()` now calls `->dontRelease()` on its `WithoutOverlapping` middleware. Previously, when a sibling sync held the lock, the duplicate dispatch was released with `releaseAfter=0` (Laravel's default), which re-popped it instantly and burned through `tries=3` in milliseconds, minting `MaxAttemptsExceededException` events on every overlap even though the actual sync was completing successfully. The schedule cycle (`Schedule::command('integrations:sync')->everyMinute()`) re-dispatches if the integration is still due, so dropping duplicates is information-free. See [Scheduled syncs](/features/scheduled-syncs).
* New [`integrations.sync.job_timeout`](/reference/configuration#sync) config (default 1800s / 30 min). [`integrations:sync`](/reference/artisan-commands#integrations-sync) reads it and passes it to the dispatched `SyncIntegration` job. The job's hardcoded constructor default also bumps from 600s to 1800s so direct dispatchers (tests, custom flows) get the safer default. 10 minutes was tight for first-run backfills against incremental APIs (e.g. multi-page Zendesk ticket windows). With the `dontRelease()` fix above, a long-running sync now holds the lock for the full timeout before the next dispatch can try, so the timeout value matters more than it did under the thundering-herd path.
* [`integrations.sync.lock_ttl`](/reference/configuration#sync) default bumped from 600s to 1800s to match the new `job_timeout`. The lock must outlast the job that holds it; otherwise the lock auto-expires mid-sync and lets a sibling dispatch start running concurrently, which is exactly what `WithoutOverlapping` exists to prevent. If you've published this config and set a custom `lock_ttl`, raise it to at least your `job_timeout`.
* Recommend cursor checkpointing for adapters with long-running incremental syncs. See [Long-running syncs and cursor checkpointing](/features/scheduled-syncs#long-running-syncs-and-cursor-checkpointing) and the adapter-side [Sync pattern](/adapters/building-adapters#sync-pattern). Per item is best when the iterator exposes a per-item callback; per page is a cheap fallback for iterators that only surface page boundaries. Companion adapter-side fix landing in `pocketarc/laravel-integrations-adapters`.

## 2.4.1

* `ResponseHelper::normalize()` now converts `stdClass` payloads to associative arrays before returning them as the parsed value, instead of passing the object through unchanged. Adapters bridging SDKs that call `json_decode($body)` without `assoc=true` (e.g. `Zendesk\API\Http::send()`) flowed `stdClass` trees straight into `Spatie\LaravelData\Data::from()`, where every `Collection<int, T>` element failed validation with "The tickets.0 field must be an array" and the request throws `SchemaDriftException`. Narrowed to `stdClass` so closures returning a typed object (e.g. a Data instance with no `->as()` set) keep current pass-through semantics. Pairs with a matching SDK-boundary fix in `pocketarc/laravel-integrations-adapters`.

## 2.4.0

* [`integration_mappings.external_id`](/reference/database-schema#integration-mappings) widened from 255 to 500 characters. The composite unique index `(integration_id, external_id, internal_type)` still fits under the InnoDB DYNAMIC 3072-byte ceiling, so no index strategy change. Covers real-world cases where adapter-bridged external IDs (e.g. attachment URLs flowing through the GitHub adapter) exceed 255 chars. See [ID mapping](/features/id-mapping#scoping). Existing deployments need a downstream `ALTER` migration since the canonical migration is the only one bumped; fresh installs get the new width on first migrate.
* `IntegrationCredentialCast::set()` now throws `InvalidArgumentException` on anything other than `null`, an array, or a `Spatie\LaravelData\Data` instance, instead of silently returning `null`. Previously, factories that pre-encrypted credentials with `Crypt::encryptString(json_encode(...))` would produce rows with `credentials = NULL` that later tripped credential-type guards in SDK clients on first use. Drop the manual encrypt and pass plain arrays; the cast handles encryption.

## 2.3.0

* [Idempotency](/core-concepts/idempotency) collapses the 2.1 transport-level `withIdempotencyKey($key)` and the 2.2 application-level `withReservation($key, $callback)` into one fluent primitive on the request builder: `$integration->at($endpoint)->withIdempotencyKey($key)->post(...)`. Every keyed call inserts a row in the new `integration_idempotency_keys` table before the closure runs; a second call with the same `(integration_id, key)` throws `Integrations\Exceptions\IdempotencyConflict`. The key is also passed to adapters via `RequestContext` so providers that implement `SupportsIdempotency` (Stripe, etc.) send it on the wire as a defense-in-depth backstop against intra-attempt SDK retries. Breaking changes: `Integration::withReservation()`, `ReservationConflict`, and the `integration_idempotency_reservations` table are removed; replace with `withIdempotencyKey($key)` on the fluent builder, the renamed `IdempotencyConflict` exception, and the `integration_idempotency_keys` table. The auto-UUID form (`withIdempotencyKey()` with no args) is gone, since keys must be application-meaningful and stable across retries; passing `null` is now a no-op rather than triggering UUID generation. The max key length is now 191 characters (was 64 on the transport side). The keyed call refuses to run inside a `DB::transaction()` and throws `RuntimeException` immediately, since an outer rollback would silently nuke the at-most-once row.

## 2.2.0

* Application-level idempotency reservations: `$integration->withReservation($key, $callback)` reserves a `(integration_id, key)` row before running the callback, throws `ReservationConflict` if another caller already reserved that key, and releases the row if the callback throws. Complements the 2.1 transport-level [`withIdempotencyKey()`](/core-concepts/idempotency) for providers that don't natively dedupe (Zendesk, Postmark, etc.). Refuses to run inside a `DB::transaction()` because an outer rollback would also roll back the reservation INSERT and break at-most-once. New `integration_idempotency_reservations` table; `integrations:prune` sweeps rows older than `pruning.reservations_days` (default 90, matching `requests_days`). Superseded by the 2.3.0 collapse, above.

## 2.1.2

* Closure docblock corrected on the fluent builder's terminal verbs (`get()`, `post()`, etc.), on `Integration::request()`, and on `RequestExecutor::execute()`. 2.1.1 used `Closure(RequestContext=): mixed`, which PHPStan reads contravariantly: an optional-arg signature means the wrapper might call the closure with no args, so a closure that requires the arg can't satisfy the type. The new declaration is a union, `(Closure(): mixed)|(Closure(RequestContext): mixed)`, matching what the wrapper actually does (zero-arg or `RequestContext` arg, decided by reflection). Adapters with typed-arg closures now pass `phpstan analyse`. No runtime change.

## 2.1.1

* Attempted PHPDoc fix for typed-arg adapter closures. The declaration shipped in this release (`Closure(RequestContext=): mixed`) is contravariantly wrong; skip to 2.1.2.

## 2.1.0

* [Idempotency keys](/core-concepts/idempotency) as a first-class builder concern: `->withIdempotencyKey($key)` on the fluent builder, with a UUID auto-generated when called with `null`. The key persists to the new `integration_requests.idempotency_key` column and is preserved across inner retry attempts so the upstream sees the same key on every try. New [`SupportsIdempotency`](/reference/contracts#supportsidempotency) marker contract; providers without it get a warning when callers attach a key, since the upstream won't dedupe.
* Provider request IDs captured on `integration_requests.provider_request_id`. Adapters report via `RequestContext::reportResponseMetadata(providerRequestId: ...)` after the SDK call. Stripe captures `Request-Id`; GitHub captures `X-GitHub-Request-Id` plus rate-limit headers. Postmark and Zendesk surface nothing (their SDKs hide response headers).
* [Adaptive rate limiting](/core-concepts/rate-limiting#adaptive-rate-limits): the `RateLimiter` honours `Retry-After` and `X-RateLimit-Remaining: 0` signals when adapters report them, suppressing subsequent requests until the window clears. Falls back to the existing bucket logic when nothing's reported.
* [Circuit breaker](/advanced/circuit-breaker) per-integration. On by default with conservative thresholds (5 consecutive failures, 60s cooldown). Opens on 5xx / connection / `RetryableException` failures; 4xx (except 429) doesn't count. New non-retryable `CircuitOpenException` short-circuits before the rate limiter and retries. Configure under `circuit_breaker.*`.
* `SchemaDriftException` replaces silent `null` returns in the request cache and the live-path Data hydration. When a Spatie Data class fails to hydrate a response (live or cache), the exception is thrown with the parsed payload and target class attached. **Behaviour change**: cached payloads that no longer hydrate now throw on first read instead of degrading invisibly.
* New `RequestContext` argument optionally available to terminal-verb closures (`fn (RequestContext $ctx) => ...`). Gives the closure access to the resolved idempotency key and the metadata-reporting hook. Zero-arg closures continue to work unchanged.

## 2.0.0

* Renamed the request API. The fluent `to()` / `toAs()` pair becomes `at()->as()`, and the standalone `request()` / `requestAs()` methods collapse into one `request()` with an optional `$responseClass` argument.

  * `$integration->to($endpoint)` is now `$integration->at($endpoint)`.
  * `$integration->toAs($endpoint, $class)` is now `$integration->at($endpoint)->as($class)`.
  * `$integration->requestAs($endpoint, $method, $class, $callback, ...)` is now `$integration->request($endpoint, $method, $callback, $class, ...)`, with `$class` optional.
  * `PendingRequest::as(class-string<Data> $class)` is the new chain step for typing responses.

  See [Making requests](/core-concepts/making-requests) for the full builder.

## 1.9.1

* Migration fix: the `integration_mappings` unique index now uses an explicit short name so the generated identifier stays within MySQL's 64-character limit. Previously the auto-generated name caused the migration to fail on MySQL.

## 1.9.0

* [`integrations:install`](/reference/artisan-commands#integrations-install) command: interactive installer that introspects a provider's `credentialDataClass()` / `metadataDataClass()` via reflection, prompts for required fields (masking secret-looking names), validates with the provider's rules, runs the health check if the provider implements `HasHealthCheck`, and upserts the `Integration` row. Non-interactive callers can supply every value via repeatable `--credential=key=value` / `--metadata=key=value` flags. Use `--force` to skip the overwrite and failed-health-check confirmations.

## 1.8.0

* [`registerDefaults()`](/core-concepts/providers#auto-registration-for-companion-packages): companion packages can auto-register their providers so users don't need to edit config after `composer require`. Defaults never override user-defined entries. See [Building adapters](/adapters/building-adapters#auto-registration) for the recommended service provider pattern.

## 1.7.1

* Testing fake: assertion methods now accept the [`METHOD:endpoint`](/testing/testing#filtering-assertions) prefix form in the endpoint argument, matching how `fake()` registers responses. A prefix that conflicts with an explicit `method:` argument raises `InvalidArgumentException` instead of silently mismatching.

## 1.7.0

* [`RetryableException`](/core-concepts/retries#retryableexception): throw to mark an error as retryable, with optional `retryAfterSeconds` and `maxAttempts`. Takes priority over `CustomizesRetry` and default status-code logic. Updated [retry decision chain](/advanced/custom-retry#how-it-composes-with-other-retry-logic).
* [`resultData`](/core-concepts/logging#structured-result-data) parameter on `logOperation()`: nullable JSON column for structured operation output, separate from `metadata`.
* [`OperationStarted`](/reference/events#operations) event: dispatched when an operation is logged with status `processing`.

## 1.6.0

* Added [`upsertByExternalId()`](/features/id-mapping#upsert-by-external-id): resolve, create-or-update, and map in a single atomic call.
* Added [`resolveMappings()`](/features/id-mapping#batch-resolution): batch-resolve multiple external IDs in two queries instead of 2N.
* `resolveMapping()`, `resolveMappings()`, and `upsertByExternalId()` now return properly generic types (`?Ticket` instead of `?Model`).
* Testing fake: [wildcard endpoint matching](/testing/testing#wildcard-endpoints) (`tickets/*.json`), respecting path segment boundaries.
* Testing fake: [method-aware fakes](/testing/testing#method-aware-fakes) (`GET:endpoint` vs `PUT:endpoint`).
* Testing fake: [integration-scoped fakes](/testing/testing#integration-scoped-fakes) via `forIntegration()` fluent API.
* Assertion methods now support optional `method` and `integrationId` [filters](/testing/testing#filtering-assertions).

## 1.5.0

* Automatic detection and honoring of `Retry-After` headers (capped by config, default 10 minutes). 429 falls back to a fixed 30s only when `Retry-After` is absent.
* Integration providers can customize retryability and delay decisions via [`CustomizesRetry`](/advanced/custom-retry).
* New `retry.retry_after_max_seconds` config setting to cap honored `Retry-After` duration (default 600s).

## 1.4.0

* Added a typed request API with typed/untyped flows, typed response reconstruction, a request executor (caching, retries, rate limiting, stale fallback) and a request cache.

## 1.3.0

* Added CI pipeline.
* Added stricter PHPStan rules and safe function wrappers.
* Confirmed PHP 8.2+ support (since Laravel 11/12 require 8.2 at a minimum).

## 1.2.0

* Sync improvements, webhook overhaul, and opinionated defaults.

## 1.1.0

* Added `SyncResult` return type.
* Added per-provider queues.
* Added rate limit backoff.
* Improved health notifications.

## 1.0.0

Initial release.
