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

# Webhooks

> Receive real-time notifications when transactions arrive, balances change, or accounts come and go. Event types, payloads, signature verification, and retries.

Webhooks let your application (or a third-party service like Zapier) receive real-time notifications when something happens in a Synci account: new transactions arrive, a balance changes, an account is added or removed, or investment holdings update.

## Setting up an endpoint

Create endpoints in the dashboard under **Developers → Webhooks**, or via the API ([Create new webhook endpoint](/api-reference/webhooks/create-new-webhook-endpoint), scope `webhooks:write`). Each endpoint has:

* **URL**: the HTTPS address Synci sends `POST` requests to.
* **Events**: the event types you want to receive (see below). You only receive events you subscribe to.
* **Account filter**: optionally limit the endpoint to specific bank accounts. Leave empty to receive events for all accounts.
* **Omit sensitive identifiers**: strips IBANs, BBANs, and similar identifiers from payloads. Required for Zapier endpoints.
* **Signing secret**: a `whsec_...` secret generated when the endpoint is created, used to verify that deliveries really come from Synci. It is shown once on creation and on rotation.

<Note>
  Webhook delivery requires an active Synci subscription.
</Note>

## Event types

| Event | Fires when |
| - | - |
| `transactions.created` | New transactions are fetched from the bank |
| `transactions.updated` | An existing transaction changes (for example, a pending transaction books) |
| `balance.updated` | An account balance changes |
| `holdings.updated` | Investment or crypto holdings for an account change |
| `account.created` | A financial account is added |
| `account.updated` | A financial account's details or status change |
| `account.deleted` | A financial account is removed |

<Info>
  **Payload versions.** Endpoints created today use payload version **v2**, which uses the event names above. Older endpoints on **v1** receive the legacy names `bank_account.created`, `bank_account.updated`, and `bank_account.deleted` instead of `account.*`, and a `bank_account` key instead of `financial_account` in payloads. The version is pinned when the endpoint is created, so existing integrations keep working unchanged.
</Info>

## Delivery format

Every delivery is a `POST` with a JSON body in a common envelope:

```json theme={"dark"}
{
  "event_type": "transactions.created",
  "event_id": "evt_0197f2a4c3de7a1b9f04d2e6b8c11a2f",
  "webhook_endpoint_id": 123,
  "created_at": "2026-07-11T10:00:00+00:00",
  "data": {
    "...": "event-specific payload"
  }
}
```

<ResponseField name="event_type" type="string">
  One of the event types above.
</ResponseField>

<ResponseField name="event_id" type="string">
  Uniquely identifies the event and is **stable across retries**: if a delivery fails and is retried, the retry carries the same `event_id`. Use it to deduplicate.
</ResponseField>

<ResponseField name="webhook_endpoint_id" type="integer">
  The endpoint the delivery was sent to.
</ResponseField>

<ResponseField name="created_at" type="string">
  When the event occurred, as an ISO 8601 timestamp.
</ResponseField>

<ResponseField name="data" type="object">
  The event-specific payload.
</ResponseField>

Requests are sent with the User-Agent `Synci-Webhooks/1.0`.

## Verifying signatures

Every delivery includes an `X-Synci-Signature` header: a hex-encoded **HMAC-SHA256** of the raw request body, keyed with your endpoint's signing secret. Always verify it before trusting the payload.

Compute the HMAC over the **raw bytes** of the body (before any JSON parsing), and compare using a constant-time comparison.

<CodeGroup>
  ```php PHP theme={"dark"}
  $secret = 'whsec_your_secret';
  $signature = $_SERVER['HTTP_X_SYNCI_SIGNATURE'] ?? '';
  $payload = file_get_contents('php://input');

  $computed = hash_hmac('sha256', $payload, $secret);

  if (! hash_equals($computed, $signature)) {
      http_response_code(401);
      exit;
  }

  // Signature valid: parse and process $payload
  ```

  ```javascript Node.js (Express) theme={"dark"}
  const crypto = require('crypto');

  // Use a raw body parser so the bytes are untouched
  app.post('/webhooks/synci', express.raw({ type: 'application/json' }), (req, res) => {
    const secret = process.env.SYNCI_WEBHOOK_SECRET;
    const signature = req.get('X-Synci-Signature') ?? '';
    const computed = crypto.createHmac('sha256', secret).update(req.body).digest('hex');

    const valid = signature.length === computed.length &&
      crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));

    if (!valid) return res.sendStatus(401);

    res.sendStatus(200); // acknowledge first
    const event = JSON.parse(req.body); // then process (ideally on a queue)
  });
  ```

  ```python Python (Flask) theme={"dark"}
  import hashlib
  import hmac
  import os

  from flask import Flask, abort, request

  app = Flask(__name__)

  @app.post("/webhooks/synci")
  def synci_webhook():
      secret = os.environ["SYNCI_WEBHOOK_SECRET"].encode()
      signature = request.headers.get("X-Synci-Signature", "")
      computed = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()

      if not hmac.compare_digest(computed, signature):
          abort(401)

      event = request.get_json()  # process on a queue
      return "", 200
  ```

  ```csharp C# (.NET) theme={"dark"}
  using System.Security.Cryptography;
  using System.Text;

  var secret = "whsec_your_secret";
  var signature = Request.Headers["X-Synci-Signature"].ToString();
  var body = await new StreamReader(Request.Body).ReadToEndAsync();

  using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
  var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
  var computed = Convert.ToHexString(hash).ToLowerInvariant();

  var valid = CryptographicOperations.FixedTimeEquals(
      Encoding.UTF8.GetBytes(computed),
      Encoding.UTF8.GetBytes(signature));

  if (!valid)
  {
      return Unauthorized();
  }

  // Signature valid: parse and process body
  ```
</CodeGroup>

### Rotating the secret

Rotate the signing secret from the endpoint's settings in the dashboard, or via the [Rotate webhook secret](/api-reference/webhooks/rotate-webhook-secret) endpoint. The new secret is returned once; update your verification code immediately, as the old secret stops being used.

## Retries and failure handling

* Your endpoint should respond with a **2xx** status within a few seconds.
* Failed deliveries (non-2xx or timeouts) are retried automatically with increasing delays: about **1 minute**, then **10 minutes**, then **1 hour**.
* If your endpoint responds `410 Gone`, Synci stops sending to it and disables the endpoint immediately.
* Endpoints that keep failing are marked unhealthy and eventually disabled automatically, and you are notified. You can re-enable an endpoint from the dashboard once it is fixed.

## Testing

Send a test event from the endpoint's page in the dashboard, or via the [Test webhook endpoint](/api-reference/webhooks/test-webhook-endpoint) endpoint. You can choose real data from your account or dummy data. Test deliveries:

* Are limited to **5 per minute**.
* Are sent once, without retries.
* Are signed exactly like real deliveries, so you can verify your signature code end to end.

## Best practices

<AccordionGroup>
  <Accordion title="Acknowledge fast, process async" icon="timer">
    Return `2xx` immediately and hand the payload to a queue or background worker. Slow responses count as failures and trigger retries.
  </Accordion>

  <Accordion title="Deduplicate on event_id" icon="copy-check">
    Retries mean you can receive the same event more than once. `event_id` is stable across retries, so skipping an `event_id` you have already processed makes repeat deliveries harmless.
  </Accordion>

  <Accordion title="Verify every signature" icon="shield-check">
    Anyone can POST to a public URL; the signature is your proof the event came from Synci.
  </Accordion>

  <Accordion title="Don't rely on ordering" icon="shuffle">
    Events are delivered independently and retries can arrive out of order. Use the `created_at` timestamp and fetch current state from the API when ordering matters.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.