# Sync webhooks

> Get notified at your own URL when records fail to sync, an error forces a sync to stop or a full read finishes. Events, payloads, issue ids, bursts and retries.

Source: https://docs.stacksync.com/two-way-sync/features/sync-webhooks

A sync webhook makes Stacksync send an HTTP `POST` to your URL when something happens on a sync. Each webhook listens to one [notification type](#notification-types). To be told about syncing issues, use `real_time_issue_notification`.

Webhooks tell you that something happened and where. They are signals rather than a record of each issue, so read the issues themselves from the [List sync issues](/api-reference/syncs/issues/list_sync_issues) API.

<Callout type="warn">
  Keep these limits in mind when you build a receiver:

  * **Not one call per issue.** An issue notification covers a failed batch of records and carries no issue id.
  * **No retries.** Stacksync sends each notification once. If your endpoint fails, that notification is lost.
  * **No rate limit.** A large failure can send many calls at once, in no guaranteed order.

  To avoid missing an issue, treat the notification as a prompt to read the issues API, and also read that API on a schedule. See [Track every issue](#track-every-issue).
</Callout>

## Create a webhook

Webhooks are managed through the API: [create](/api-reference/syncs/webhooks/create_sync_webhook), [list](/api-reference/syncs/webhooks/list_sync_webhooks), [get](/api-reference/syncs/webhooks/get_sync_webhook), [update](/api-reference/syncs/webhooks/update_sync_webhook) and [delete](/api-reference/syncs/webhooks/delete_sync_webhook). These operations read and change a webhook's settings: its URL, notification type and API key. The events themselves are only ever sent to your URL, and no API lists past notifications.

```bash
curl -X POST "https://api.stacksync.com/v1/workspaces/{workspace_id}/bases/{base_id}/webhooks?region=besg" \
  -H "Authorization: Bearer $STACKSYNC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/stacksync",
    "notification_type": "real_time_issue_notification",
    "api_key": "a-secret-your-endpoint-checks"
  }'
```

The URL must use https on a public internet host and answer an `OPTIONS` request within 2 seconds. Stacksync only runs this check when the webhook is created or updated. A sync can hold one webhook per notification type and URL.

## Notification types

Choose a type with `notification_type` when you create the webhook. A webhook receives only its own type.

| `notification_type`                                             | What it tells you                      | Sync state                           | Extra fields                  |
| --------------------------------------------------------------- | -------------------------------------- | ------------------------------------ | ----------------------------- |
| [`real_time_issue_notification`](#real_time_issue_notification) | Records could not be written to an app | Keeps running                        | `app_id`, `internal_table_id` |
| [`base_activity`](#base_activity)                               | Stacksync stopped the sync             | Stopped until someone switches it on | None                          |
| [`backfill`](#backfill)                                         | A full read finished                   | Keeps running                        | None                          |

### `real_time_issue_notification`

Use this type to learn about syncing issues, the same ones shown in the [Issues dashboard](/two-way-sync/features/issues-dashboard).

**What it means:** Stacksync tried to insert, update or delete records in an app, and at least one could not be written, for example because of a validation rule or a datatype mismatch. Those records now have open issues and the sync keeps running.

**When it is sent:**

* Once for each batch with failures, for each table. See [Bursts](#bursts).
* Again when a record with an open issue fails again.

**When it is not sent:**

* Some issues are recorded without a notification, such as a record matched as a duplicate of one already synced, or an existing issue updated when the record is read again. Reading the issues API on a schedule picks these up.
* An issue is resolved. There is no notification for a retry that succeeds, a revert or an ignore.

**What to do:** the notification names the sync, app and table, but not the records or the error. Read them from the issues API, as described in [Track every issue](#track-every-issue).

```json
{
  "notification": {
    "created_time": "2026-11-05T14:02:11.482913",
    "notification_type": "real_time_issue_notification",
    "msg": "Some records had issues syncing on base: \"3f6c1d2e-8a4b-4c7d-9e21-5b6f7a8c9d01\" for the app: \"b71d04e5\", to view the issue(s) go to the issues tab in your workspace",
    "base_id": "3f6c1d2e-8a4b-4c7d-9e21-5b6f7a8c9d01",
    "app_id": "b71d04e5",
    "internal_table_id": "4d20ffb3"
  }
}
```

### `base_activity`

Use this type to learn that a sync has stopped. Despite its name, it does not report general activity on the sync.

**What it means:** Stacksync stopped the sync because it could not continue. No records sync until the cause is fixed and someone switches the sync on again.

**When it is sent:**

* An error Stacksync cannot recover from occurs.
* A temporary pause runs out of attempts to resume.
* The same error repeats too many times in a short period.
* The credentials of one of the sync's apps are found to be invalid. A single expired credential can send this more than once.

**When it is not sent:**

* A person switches or edits the sync.
* The sync fails to switch on.
* The sync is paused temporarily and resumes by itself.
* The schema of a connected app changes.

Rarely, a notification can follow a manual switch off, when a read in progress fails at that moment.

**What to do:** treat it as an alert. The payload does not name the app or the error, so show `msg` to a person or open the sync in Stacksync. `msg` is usually an HTML fragment, the same content as the alert email. For invalid credentials it is plain text.

```json
{
  "notification": {
    "created_time": "2026-11-05T14:02:11.482913",
    "notification_type": "base_activity",
    "msg": "<p>Hi there,</p><p>Your Sync <strong>CRM to Postgres</strong> in workspace <strong>Acme</strong> is forced to be paused because of the following error...</p>",
    "base_id": "3f6c1d2e-8a4b-4c7d-9e21-5b6f7a8c9d01"
  }
}
```

### `backfill`

Use this type to learn that a sync has finished reading its existing records.

**What it means:** every full read of the sync has ended and the changes they found have been processed.

**When it is sent:** once, when all of these are true:

* The sync is running.
* Every full read process of the sync has ended.
* No changes from before the end of those reads are still waiting to be written.

Stacksync checks this periodically, so the notification can come some time after the read ends.

**When it is not sent:**

* A full read is still running on any table, or its changes are still being written.
* The sync is not running.

**What to do:** continue whatever was waiting for the initial data. The message says the process completed successfully, but that only means it ended: some records may still have failed. Check [List sync issues](/api-reference/syncs/issues/list_sync_issues) if you need every record to be in place.

```json
{
  "notification": {
    "created_time": "2026-11-05T14:02:11.482913",
    "notification_type": "backfill",
    "msg": "The back-fill process has been completed successfully for your base CRM to Postgres (id: 3f6c1d2e-8a4b-4c7d-9e21-5b6f7a8c9d01).",
    "base_id": "3f6c1d2e-8a4b-4c7d-9e21-5b6f7a8c9d01"
  }
}
```

## Payload

Every notification is a JSON object with a single `notification` key, sent with these headers:

```http
Content-Type: application/json
Authorization: Bearer <api_key>
```

`api_key` is the value you set on the webhook. Check it to confirm the call comes from Stacksync. Notifications are not signed, and the API returns this key when a webhook is read, so treat it as a secret.

| Field               | Type   | Present             | Meaning                                                                                         |
| ------------------- | ------ | ------------------- | ----------------------------------------------------------------------------------------------- |
| `created_time`      | string | Always              | When the notification was built, ISO 8601 without a UTC offset. It is not an ordering guarantee |
| `notification_type` | string | Always              | One of the [notification types](#notification-types)                                            |
| `msg`               | string | Always              | Text for people. Do not parse it: its wording can change                                        |
| `base_id`           | string | Always              | The sync                                                                                        |
| `app_id`            | string | Issue notifications | The app the records could not be written to. Matches `destination_app_id` on the issue          |
| `internal_table_id` | string | Issue notifications | The table pair. Use it as `table_id` when you list issues                                       |

The payload has no event id, no workspace id, no region and no count of the records affected. Keep the region of each sync on your side, because the issues API needs it.

## Issue ids

Notifications do not contain issue ids. `issue_id`, the failed record, the changed columns and the error message are all returned by [List sync issues](/api-reference/syncs/issues/list_sync_issues).

A record has at most one open issue per app and table. If it fails again, the same issue is overwritten: it gets a new `issue_id` and a new `last_modified_time` and `issue_message`, and keeps its `created_time`. So:

* `issue_id` identifies one failure. Use it to avoid processing the same failure twice, and to [resolve the issue](/api-reference/syncs/issues/resolve_sync_issues).
* To follow a record across repeated failures, use `base_id`, `destination_app_id`, `destination_table_id` and `source_record_id` or `destination_record_id`. A stored `issue_id` stops working once the record fails again.

## Delivery and failures

Stacksync sends each notification once and does not look at your response.

| What happens on your side                                              | Result                                                                                                               |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Your endpoint answers with any status                                  | The response has no effect, and a `2xx` is not treated differently from an error                                     |
| It answers with an error, is unreachable or has an invalid certificate | The notification is lost. There is no retry and no replay                                                            |
| It is slow to answer                                                   | The sync does not wait for you, but answer quickly: a call still open when a Stacksync worker restarts is dropped    |
| It redirects                                                           | The redirect is followed, and a `301` or `302` usually turns the `POST` into a `GET` with no body. Use the final URL |
| It fails repeatedly                                                    | The webhook stays active, and you are not alerted that deliveries are failing                                        |

Failed deliveries never affect your data. The sync keeps running, and the issues stay in the [Issues dashboard](/two-way-sync/features/issues-dashboard) and the API until they are resolved. Delivery is best effort: a rare notification can also be lost on the Stacksync side, for example during a deployment.

## Bursts

* **One call per failed batch.** Stacksync writes records in batches, and each batch with failures sends one call for its table. The number of calls depends on the batching rather than on the number of records, so 10,000 failed records send neither 10,000 calls nor a single one.
* **No rate limit or grouping.** Calls are not throttled or merged, and you cannot configure a limit. The limit of one alert email per sync every 6 hours applies to email only.
* **Concurrent and unordered.** Batches run in parallel and each call is sent independently, so calls can overlap and arrive out of order.
* **Repeats.** Records that keep failing send new notifications, each looking the same apart from `created_time`.
* **Possibly early.** The notification can arrive just before its issues can be read. Wait a few seconds before reading them.

If your endpoint receives more than it can handle, the calls it drops are not sent again, so keep the endpoint light.

## Track every issue

To act on each issue reliably, including during bursts and outages:

1. **Answer immediately.** Check the `Authorization` header, put `base_id` and `internal_table_id` on a queue, and answer `200` before doing any other work.
2. **Group the calls.** Wait a few seconds and keep one pending read per `base_id` and `internal_table_id`, which turns a burst into a single read.
3. **Read the issues.** Call the API below and follow `pagination.next_page` until it is absent. Results are ordered by most recently modified first, so you can stop at issues older than your last read. Filter on `destination_app_id` if you only want one app.
4. **Skip what you have seen.** Store each `issue_id` you have processed, because every new failure has a new one.
5. **Read on a schedule too.** Run the same read every few minutes for each sync, without `table_id`. This picks up anything a lost notification missed.

```bash
curl "https://api.stacksync.com/v1/workspaces/{workspace_id}/issues?region=besg&base_id={base_id}&table_id={internal_table_id}&page_size=1000" \
  -H "Authorization: Bearer $STACKSYNC_API_KEY"
```

Each issue in the answer holds `issue_id`, the action tried, the error message, the changed columns and the record ids. [Pagination](/api-reference/syncs/issues/list_sync_issues) and every field are described on the operation's page.