# Webhooks

Apideck pushes a webhook event to your delivery URL when a supported change is detected in a connected integration,
instead of you polling the API on a schedule. Subscribe via the Apideck admin or the Webhook subscription
API, and Apideck normalizes events across every connector that supports webhook events, including some
that don't natively support webhooks, through Virtual Webhooks.

- [Follow a delivery through your receiver](#follow-a-delivery-through-your-receiver)
- [Build a durable inbox and worker](#build-a-durable-inbox-and-worker)
- [Recover from failures and out-of-order events](#recover-from-failures-and-out-of-order-events)
- [Delivery, retries and failure handling](#delivery-retries-and-failure-handling)
- [Test before enabling customer traffic](#test-before-enabling-customer-traffic)
- [Pull versus Push](#pull-versus-push)
- [Virtual Webhooks](#virtual-webhooks)
- [How to subscribe to webhook events?](#how-to-subscribe-to-webhook-events)
- [How to activate webhook events for integrations?](#how-to-activate-webhook-events-for-integrations)
- [Vault connection events and their payloads](#vault-connection-events-and-their-payloads)

Learn more about [what's a webhook](https://blog.apideck.com/what-is-a-webhook) on our blog.

## Follow a delivery through your receiver

The walkthrough illustrates an application architecture, not a delivery-time guarantee. Apideck's own retry schedule is described in [Delivery, retries and failure handling](#delivery-retries-and-failure-handling). Select a failure scenario, then use **Next step** or **Play animation** to follow the event. The controls work with a keyboard; reduced-motion mode keeps manual stepping available.

For a business event, separate these five stages:

1. **Receive:** accept the POST at your webhook route. Keep subscription validation separate; it is not a business event and must not create a job.
2. **Verify:** use the [signature verification algorithm](/guides/webhook-signature-verification), including its recursive key sorting. Do not substitute an HMAC of arbitrary JSON serialization. Resolve the verified event's consumer and connection against your own tenant mapping.
3. **Persist:** store a receipt and schedule work durably. Use `x-apideck-idempotency-key` to identify repeated deliveries in your inbox, scoped to your application. Do not use a process-local cache as the only deduplication store.
4. **Acknowledge:** return success after the durable commit. If persistence fails, do not claim acceptance. A successful HTTP response means your receiver has taken responsibility, not that the invoice, employee or connection has finished syncing.
5. **Reconcile:** let a worker fetch the affected record through the Unified API using the same consumer and connector, update your local state and record the outcome.

The HTTP request should not wait for a full accounting sync. You control your queue's processing and retry policy independently of webhook delivery.

## Build a durable inbox and worker

A practical receiver has three durable records. The names below are suggestions for your own storage, not Apideck API resources.

| Record                      | Store                                                                                                     | Purpose                                                                                       |
| --------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Inbox receipt               | Application ID, delivery key, event type, resolved tenant/connection, received time and processing status | Recognize a repeated delivery and audit its processing without scheduling it twice.           |
| Job or transactional outbox | Inbox reference, affected resource reference, attempt count, next attempt time and last error category    | Keep work recoverable when a process stops after accepting the event.                         |
| Local projection            | Your tenant-scoped copy of the resource and the last successful reconciliation time                       | Give your application a current view without treating an event payload as an ordered history. |

Enforce uniqueness in the database, not with a “check then insert” in application code. Two receiver instances can accept the same delivery concurrently. Insert the receipt and job in one database transaction, or use a transactional outbox when the queue is a separate service. Otherwise, a crash between “remember the key” and “enqueue the job” can silently lose work.

When the unique constraint finds an **already committed receipt**, acknowledge the duplicate without adding a second job. Let the original job's state drive recovery. Retaining a delivery key does not prove the worker succeeded; expose pending and failed jobs separately.

Choose a retention period for deduplication records that fits your replay and recovery procedures. Minimize stored business data and protect any event payload you retain. [Zero Data Retention](/guides/security-and-data-handling#zero-data-retention-and-its-boundaries) describes Apideck's boundaries; it does not configure retention in your own inbox, queue or logs.

## Recover from failures and out-of-order events

Treat a webhook as a reason to reconcile current state. Do not assume events arrive once, in order, or with every field your application needs. A delayed “updated” event should not roll a record back to an older state. Fetch the current record, serialize or coordinate concurrent work for the same tenant and resource, and keep a periodic reconciliation path for missed or unsupported changes.

| Situation                               | Receiver or worker action                                                                                                         | Avoid                                                                             |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Signature missing or invalid            | Reject before tenant routing or business processing; record a safe diagnostic.                                                    | Logging secrets or accepting a payload because its event type looks familiar.     |
| Inbox/database unavailable              | Do not acknowledge durable acceptance; alert on receiver failures.                                                                | Returning success before work is committed.                                       |
| Delivery key already committed          | Acknowledge the receipt and leave the original job responsible for processing.                                                    | Creating another bill, payment or notification from the duplicate.                |
| Worker read returns 429                 | Keep the job pending; retry with bounded backoff, jitter and provider retry timing when supplied.                                 | An immediate retry loop or resubmitting the webhook to your receiver.             |
| Worker receives 401 or 403              | Check API credentials, tenant/connector selection, connection state and permissions; surface a repair action.                     | Automatically asking every customer to reconnect for every authorization failure. |
| Record read returns 404                 | Check the event type, resource ID and connection; distinguish a supported deletion from a wrong reference or inaccessible record. | Deleting local data solely because one read returned 404.                         |
| Worker stops after updating local state | Make reprocessing safe with a tenant/resource upsert and durable job state.                                                       | Assuming inbox deduplication alone gives exactly-once business effects.           |
| Retries exhausted                       | Move the job to a failed-work queue with its reason and a controlled replay action.                                               | Silently dropping the event or retrying forever.                                  |

If processing causes an external write, give that business operation its own duplicate-prevention and reconciliation strategy. A unique webhook delivery key cannot make a downstream payment or invoice write exactly once. After an ambiguous timeout, inspect [parent and child requests](/logs#follow-parent-and-child-requests) before repeating the write.

## Delivery, retries and failure handling

Native and virtual webhook events go through the same delivery pipeline.

### What counts as a successful delivery

Apideck sends each event as an HTTP `POST` to your delivery URL with these headers:

| Header                      | Value                                                                                       |
| --------------------------- | ------------------------------------------------------------------------------------------- |
| `x-apideck-event-type`      | The event type, for example `accounting.invoice.updated`.                                   |
| `x-apideck-idempotency-key` | A key derived from the event, the record and the time of the change. It is the same on every delivery attempt. |
| `x-apideck-signature`       | An HMAC-SHA256 signature of the body. See [signature verification](/guides/webhook-signature-verification). |
| `User-Agent`                | `Apideck/WebhookClient`                                                                     |

A delivery succeeds when your endpoint returns a `2xx` status within **60 seconds**. Any other status, a timeout, or a connection or DNS failure counts as a failed attempt.

### Retry schedule

A failed delivery is retried up to **10 attempts in total**, with increasing delays between attempts:

| Attempt | Delay after the previous attempt |
| ------- | -------------------------------- |
| 1       | Immediate                        |
| 2       | 3 minutes                        |
| 3       | 5 minutes                        |
| 4       | 10 minutes                       |
| 5       | 30 minutes                       |
| 6       | 1 hour                           |
| 7       | 2 hours                          |
| 8       | 4 hours                          |
| 9       | 8 hours                          |
| 10      | 12 hours                         |

All 10 attempts span about 28 hours. Retries stop early if the webhook is disabled in the meantime.

Each event is retried on its own schedule, so events can arrive out of order. Each attempt carries a new `event_id` in the body, and therefore a new signature. Use `x-apideck-idempotency-key` to recognize repeated deliveries of the same event, not `event_id`.

### After the final attempt

When the 10th attempt fails, Apideck checks whether any delivery to the same URL in your application succeeded in the last 24 hours:

- **No successful deliveries:** Apideck treats the endpoint as down and disables every webhook in your application that uses that delivery URL. The webhook's `disabled_reason` is set to `retry_limit`, shown in the dashboard as **Retry limit reached**. No further events are sent to it until you re-enable it.
- **Other deliveries succeeded:** the webhook stays enabled and the failed event is set aside instead of being retried again. If your application retains logs, Apideck support can redeliver it within 90 days as long as the webhook is still enabled for the same URL. Contact support with the event's idempotency key.

To recover a disabled webhook, fix your endpoint, then set the webhook's `status` back to `enabled` in the dashboard or with the [update webhook endpoint](/apis/webhook/reference/webhooks#webhooksUpdate). Events that occurred while it was disabled are not delivered afterwards, so run your periodic reconciliation to catch up.

### Delivery logs

Every attempt is logged with its status code, failure reason, request and response. View them in the dashboard under **Logs → Webhooks**, where each event expands to show its individual attempts, or query them with the [webhook event logs endpoint](/apis/webhook/reference/webhooks#eventLogsAll) (`GET /webhook/logs`). Filter by `success`, `status_code`, `execution_attempt`, `event_type`, `consumer_id`, `service` and date range to find failed deliveries.

## Test before enabling customer traffic

Use a test consumer and a connector/resource with verified event support. Make one controlled change and record the expected resource identity before testing the receiver.

- **Validation:** the subscription-validation POST returns HTTP 200 and creates no inbox job.
- **Integrity:** altering a signed body fails verification and creates no business work.
- **Concurrency:** delivering the same event concurrently produces one committed receipt and one job.
- **Crash recovery:** stop the worker after receipt, then restart it; the pending job still runs.
- **Throttling:** simulate a 429 on the worker's record read; verify a delayed, bounded retry and a visible final outcome.
- **Ordering:** deliver an older event after a newer change; the local projection still reflects the current record.
- **Isolation:** the job uses the event's resolved customer connection, never a shared default consumer.
- **Gaps:** periodic reconciliation discovers a change that did not produce a supported webhook.

Track accepted, rejected, duplicate, pending, reconciled and failed counts separately. Measure the age of your oldest pending job and time since the last successful reconciliation. For diagnosis, correlate the receipt, worker attempt and Unified API request in [Logs](/logs); exclude API keys, signatures and unnecessary business payloads from application logs.

## Pull versus Push

Polling (pull) means your application repeatedly requests the API on a schedule to check for new data.
Webhooks (push) mean you register a callback URL once, and the API sends a POST request to it the moment a
matching event occurs. Polling gives you control over request timing but adds latency and wasted requests
when nothing has changed; webhooks deliver detected changes asynchronously but require your endpoint to be reachable and
capable of validating and processing incoming requests.

### When to use webhooks and when to poll?

Use webhooks when the data is time-sensitive and you need to react as soon as it changes. They're also more
resource-efficient, since your endpoint is only called when an event actually happens.

Use polling when updates aren't time-sensitive, or you need a guaranteed refresh even if nothing changed
(for example, refreshing a user count once an hour regardless of whether it moved).

The use cases for webhooks are numerous. See [Apideck: What is a webhook?](https://blog.apideck.com/what-is-a-webhook)
on our blog for common examples.

## Virtual Webhooks

Some downstream APIs don't natively support webhooks. For those, Apideck's Virtual Webhooks poll the
downstream API on your behalf and emit the same unified webhook events you'd get from a native webhook,
so your integration code doesn't need to know which connectors support webhooks natively and which don't.

Apideck's polling engine monitors records inside the downstream API for changes and converts them into
unified webhook events, which your application can use to trigger a refresh. By default, Apideck checks each enabled resource for changes every 24 hours, and the first check records a baseline without sending `created` events for existing records. Delivery timing depends on polling and connector behavior; virtual webhooks are not instantaneous.

Apideck handles the sync and polling complexity for you, including pagination, automatic retries, rate
limits, and change tracking, without persisting your data on Apideck's side.

## How to subscribe to webhook events?

Subscribe to Unify events either through the Apideck admin dashboard or through the Webhook subscription API.

### Via the Apideck admin

Open **Webhooks** in the dashboard: [https://platform.apideck.com/webhooks](https://platform.apideck.com/webhooks)
to manage existing webhook subscriptions.

Click **Add Webhook** to create a new subscription.

Enter the delivery URL of the webhook endpoint on your system, and select the Unify API to choose which
events you want to receive.

Apideck validates the delivery URL when you create the subscription, and expects it to respond with an HTTP
200 to the validation POST request. See [how webhook delivery URL validation works](https://help.apideck.com/en/articles/4232258)
for details. The validation request carries the header `x-apideck-event-type: apideck.subscription.created`
and this body:

`{"message":"Validating delivery URL"}`

Once you click **Create**, you're subscribed to the Unify webhook events you selected.

### Via the Apideck Webhook subscription API

Manage webhook subscriptions programmatically through the [Webhook API](/apis/webhook/reference/webhooks)
or test it live in the [API explorer](https://developers.apideck.com/apis/webhook/api-explorer).

## How to activate webhook events for integrations?

For Unify to forward webhook events from a specific integration, that integration must support webhook
events, and Unify's endpoint must be registered with it to receive them.

For integrations whose API supports subscription management, Apideck auto-registers the subscription for
you. For integrations that don't support auto-registration, you or the customer activating the integration
must manually enter the Unify endpoint in that integration's own web portal.

Follow the selected connector's maintained setup guide for its registration requirements. For QuickBooks, start with [connector setup](/apis/accounting/quickbooks). Enable virtual webhooks, where available, under **Webhooks → Connectors** in the dashboard. A subscription alone does not establish support for every resource or event.

### Verify and operate your receiver

1. Handle the subscription-validation request separately from business events and return HTTP 200.
2. Follow the [webhook signature verification guide](/guides/webhook-signature-verification) before trusting incoming data.
3. Persist accepted events durably before acknowledging them, then process them asynchronously.
4. Deduplicate event deliveries using `x-apideck-idempotency-key`; do not assume each delivery represents a new change.
5. Fetch the affected record through the Unified API to reconcile your application's state. Keep a periodic reconciliation path for gaps or unsupported events.

Test with a controlled change in a test account, confirm receipt, and inspect the resulting API request in [Logs](/logs). Recording a payment event is not the same as transferring money.

## Vault connection events and their payloads

Alongside events from the Unified APIs (new invoice, updated employee, ...), Vault emits its own events whenever something changes about a **connection** itself, rather than the data behind it. Subscribe to these the same way as any other event, [via the Apideck admin or the Webhook subscription API](#how-to-subscribe-to-webhook-events), by selecting the **Vault API** and picking the event you need.

Every Vault connection event delivers the same shape: the full [Connection resource](https://developers.apideck.com/apis/vault/reference#tag/Connections/operation/connectionsOne) as `entity`, plus an `entityType` of `Connection`:

```json
{
  "entity": {
    "id": "accounting+quickbooks",
    "application_id": "dSBdXd2H6Mqwfg0atXHXYcysLJE9qyn1VwBtXHX",
    "consumer_id": "test_consumer_id",
    "service_id": "quickbooks",
    "unified_api": "accounting",
    "auth_type": "oauth2",
    "state": "callable",
    "health": "ok",
    "enabled": true,
    "settings": {},
    "metadata": {},
    "created_at": "2021-09-10T10:39:49.628Z",
    "updated_at": "2021-09-10T10:39:52.715Z"
  },
  "entityType": "Connection"
}
```

Which event you get, and which field on that same payload is the signal to act on, depends on what changed:

- **`vault.connection.callable`**, fires the moment a connection transitions from any other state to `callable`: OAuth scopes were granted (or, for non-OAuth connectors, the entered credentials were validated). Good place to kick off an initial sync.
- **`vault.connection.updated`**, a general-purpose event that fires whenever any connection data changes as a result of a Vault-driven action (adding/updating credentials, an OAuth callback, importing a connection, updating settings). Its payload includes the connection's current `state`, so this is also how you find out a connection became `invalid`, not a dedicated `vault.connection.invalid` event. See the [multi-company accounting guide](/guides/multi-company-accounting#reading-metadatacompany_id) for using this event to detect an organization change via `metadata.company_id`.
- **`vault.connection.disabled`**, fires when a connection's `enabled` property changes from `true` to `false`.
- **`vault.connection.revoked`**, fires when OAuth access is explicitly revoked and that revocation is confirmed at the integration's side.
- **`vault.connection.token_refresh.pending`**, an _early warning_, not a failure signal. Vault refreshes OAuth tokens proactively and retries roughly every 15 minutes if an attempt fails. This fires on the first failed attempt, while the connection stays `callable` and `health` reads `pending_refresh` for up to 48 hours as Vault keeps retrying.
- **`vault.connection.token_refresh.failed`**, the _terminal_ event. It fires only once that 48-hour retry window elapses with no successful refresh, at which point credentials are cleared and the connection is no longer callable. Don't treat `token_refresh.pending` as a precursor you can ignore until this fires: by design it can be up to 48 hours later. See the [Migrating Integrations guide](/guides/migrating-integrations#webhooks) for how to enable this event.
- **`vault.connection.downstream.degraded`** / **`vault.connection.downstream.recovered`**, a connection can stay `callable` while the integration itself is temporarily unreachable. These two events cover that separately from `state`.

>
> One case isn't covered by any event above: a request to the integration comes back unauthorized
> even though the stored credentials didn't look expired or invalid locally, for example access was
> revoked earlier than its recorded expiry, or the connector uses static (non-OAuth) credentials
> with no refresh cycle at all. Vault still marks the connection `invalid`, but that specific path
> doesn't emit a webhook and, since it isn't part of the token-refresh retry cycle, won't
> self-correct either. Calling
> [`validateConnectionState`](https://developers.apideck.com/apis/vault/reference#operation/validateConnectionState)
> yourself forces a fresh check; otherwise, treat a 401/unauthorized response from your own Unified
> API or Proxy calls as the signal for this specific case.

For the full connection lifecycle these events move through, and what each `state` and `health` value means, see [Reacting to state changes without polling](/guides/connection-states#reacting-to-state-changes-without-polling) in the Vault Connection States guide.
