# Guide to Swan Webhooks: Features and Best Practices

Swan webhooks tell your product what is happening inside the bank accounts, cards and payments you build on Swan. Many changes happen outside your control: a card payment is authorised, a transfer is booked, a user accepts a consent, an onboarding is finalised. Swan updates its own resources when they do, and a webhook subscription is how your systems hear about it.

This guide covers how Swan webhook subscriptions are set up and authenticated, what a notification contains and why it is deliberately thin, how Swan retries and orders deliveries, and the practices that keep a Swan receiver reliable.

## What are Swan webhooks?

A Swan webhook subscription belongs to a project. It has a label, an endpoint URL, an optional secret, a list of event types and a status. When one of those events happens, Swan sends a `POST` to the endpoint with a small JSON body that names the event and the resource it concerns. It does not include the resource itself: for security reasons, Swan keeps notifications to identifiers, and you query the API for the details.

Swan does not sign webhooks. If you set a secret on the subscription, every request carries it in the `x-swan-secret` header, and Swan describes it as "a password, known only to you and Swan". Your endpoint authenticates the request by comparing the header with the secret it has stored. A subscription without a secret sends nothing to check.

## Swan webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Dashboard > Developers > Webhooks**, or the `addWebhookSubscription` GraphQL mutation |
| Subscription model | Per project, each with its own endpoint, secret and event types |
| Method and format | `POST`, JSON body |
| Body | `eventType`, `eventId`, `eventDate`, `projectId`, `resourceId` |
| Authentication | The subscription's optional secret, as plain text, in `x-swan-secret` |
| Signature and timestamp | None |
| Response | `200 OK` within 10 seconds |
| Retries | Every 30 seconds, up to 7 times, for errors and timeouts |
| Manual retry | **Retry** in the Dashboard, or the `replayWebhookEvent` mutation |
| Retention | Webhook events are kept for 90 days |
| Delivery and ordering | At least once, and not in order |
| Subscription limits | 10 subscriptions per event in Sandbox, 5 in Live |
| Source IPs | `52.210.172.90`, `52.51.125.72`, `54.194.47.212` (Sandbox and Live) |

## Common events

Swan documents around 76 event types. The part before the dot names the resource to query, and the part after it names what happened.

| Area | Example events |
| --- | --- |
| Onboarding and account holders | `Onboarding.Created`, `Onboarding.Updated`, `AccountHolder.Created`, `AccountHolder.Updated` |
| Accounts | `Account.Created`, `Account.Updated`, `Account.Closing`, `Account.Closed`, `AccountStatement.Generated` |
| Memberships | `AccountMembership.Created`, `AccountMembership.Bound`, `AccountMembership.Suspended`, `AccountMembership.Disabled` |
| Transactions | `Transaction.Pending`, `Transaction.Booked`, `Transaction.Rejected`, `Transaction.Released`, `Transaction.Canceled`, `Transaction.Upcoming` |
| Cards | `Card.Created`, `Card.Updated`, `DigitalCard.Created`, `DigitalCard.Updated` |
| Consent | `Consent.Created`, `Consent.Started`, `Consent.Granted`, `Consent.Refused`, `Consent.Expired`, `Consent.Canceled` |
| Merchants | `MerchantProfile.Created`, `MerchantPayment.Authorized`, `MerchantPayment.Captured`, `MerchantPayment.Disputed` |
| Users | `User.Joined`, `User.Updated`, `User.Deactivated` |

There are also events for billing, capital deposits, funding sources and limits, received direct debit mandates, standing orders and supporting documents.

Every notification has the same five fields:

```json
{
  "eventType": "Transaction.Booked",
  "eventId": "$YOUR_EVENT_ID",
  "eventDate": "2021-08-18T09:35:46.673Z",
  "projectId": "$YOUR_PROJECT_ID",
  "resourceId": "$YOUR_RESOURCE_ID"
}

```

The placeholders are Swan's. `eventId` identifies the event and `resourceId` the resource, so for `Transaction.Booked` you would fetch the transaction with that ID from the API.

> **Inspect Swan webhooks as they arrive.** Point a Sandbox subscription at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see each notification's headers and body, with no account or setup required.

## Setting up Swan webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`. Use a different one for Sandbox and Live.

1. In the Swan Dashboard, go to **Developers > Webhooks**.
2. Add a subscription with a label, your HTTPS endpoint, the secret and the event types you need.
3. Enable it.

The same subscription through the API:

```graphql
mutation AddPaymentEventsSubscription {
  addWebhookSubscription(
    input: {
      label: "Payment events"
      endpoint: "https://api.yourcompany.com/webhooks/payments"
      secret: "$YOUR_WEBHOOK_SECRET_PAYMENTS"
      eventTypes: ["Transaction.Booked", "Transaction.Pending", "Transaction.Rejected"]
      status: Enabled
    }
  ) {
    ... on WebhookSubscriptionSuccessPayload {
      webhookSubscription { id label endpoint eventTypes }
    }
  }
}

```

To route through Hookdeck, create a Hookdeck source with the **Swan** type and use its URL as the subscription's endpoint. The source has one field, **Webhook Secret**: paste the secret you set on the subscription. Hookdeck reads it from the `x-swan-secret` header and compares the whole value. The source accepts `POST` only, which is the method Swan uses.

Set a webhook secret on the Swan webhook subscription. Subscriptions without one send nothing to verify, so if you leave it empty you would have to leave verification off on the source too, and nothing would authenticate the requests.

## Securing Swan webhooks

Read `x-swan-secret`, compare it with your stored secret in constant time, and reject the request if either is missing. Swan recommends checking the header on every request.

```javascript
const crypto = require('crypto');
const express = require('express');

const WEBHOOK_SECRET = process.env.SWAN_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/swan', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('x-swan-secret');
  if (!WEBHOOK_SECRET || typeof received !== 'string' || !safeEqual(received, WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  let notification;
  try {
    notification = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Store notification.eventId, respond, then query the API for notification.resourceId.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

WEBHOOK_SECRET = os.environ.get("SWAN_WEBHOOK_SECRET", "")

def verify_swan_request(headers) -> bool:
    if not WEBHOOK_SECRET:
        return False  # never accept when unconfigured
    received = headers.get("x-swan-secret")  # case-insensitive in Starlette and Flask
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), WEBHOOK_SECRET.encode("utf-8"))

```

The secret is static. It authenticates the sender, not the body, and with no timestamp a captured request can be replayed. Swan's design limits the damage: a notification holds only identifiers, and you read the actual state from the authenticated API. Serve the endpoint over HTTPS, and rotate the secret with `updateWebhookSubscription` if it might have leaked. Allowlisting Swan's three IP addresses adds a second check, but the secret is what authenticates a request.

> **Make Swan webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `x-swan-secret` header, answers Swan inside its 10-second window, and queues every notification durably before your service sees it.

## Swan webhook limitations and pain points

### Ten seconds, seven retries, then silence

**The Problem:** A short outage on your side leaves gaps in your copy of accounts and transactions.

**Why It Happens:** Swan needs a `200 OK` within 10 seconds. Errors and timeouts are retried every 30 seconds, up to 7 times, so an outage of a few minutes exhausts the retries. After that, the event waits for a manual retry, and after 90 days it is gone.

**Workarounds:**

* Respond immediately and handle the event asynchronously, as Swan recommends.
* Watch the webhook logs in the Dashboard, or query `webhookEventLogs`, and replay failures with `replayWebhookEvent`.
* Reconcile important resources, such as transactions, against the API on a schedule.

**How Hookdeck Can Help:** Hookdeck accepts each notification as soon as it arrives and stores it, so Swan's retries are rarely needed. Hookdeck then delivers to your service with [retries](/docs/retries) you configure, and you can replay any event after a fix.

### Events arrive out of order

**The Problem:** A transaction is marked released and then booked, or your status goes backwards.

**Why It Happens:** Swan does not guarantee order. Its own example is a card payment: `Transaction.Pending` comes first, but `Transaction.Booked` and `Transaction.Released` can arrive in either order even though the debit always happens before the release.

**Workarounds:**

* Do not derive state from the event name alone. Query the resource and store the status the API returns.
* If you must use the event, compare `eventDate` with the last one you applied and skip older ones.

**How Hookdeck Can Help:** Because every notification is a pointer to a resource, Hookdeck can queue them and [cap the delivery rate](/docs/destinations#set-a-max-delivery-rate) to your service, so a burst of events never turns into a burst of API calls while your handler reads the current state.

### At-least-once delivery

**The Problem:** The same `eventId` arrives more than once.

**Why It Happens:** Swan chose at-least-once delivery on purpose, preferring a duplicate to a lost event.

**Workarounds:**

* Log each `eventId` you process and skip repeats, as Swan suggests.
* Make handlers idempotent: re-reading a resource and writing its current state is safe to repeat.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated notifications before they reach your service, based on `eventId`.

### Subscription limits

**The Problem:** You cannot add another subscription for an event you already subscribe to.

**Why It Happens:** Swan limits each event to 10 subscriptions in Sandbox and 5 in Live. Older subscriptions above the limit stay active, but block new ones.

**Workarounds:**

* Subscribe each endpoint only to the events it needs.
* Group events by priority, as Swan suggests, with one endpoint for critical events such as `Consent.Granted` and `Account.Closing` and another for high-volume ones such as `Transaction.Booked`.

**How Hookdeck Can Help:** One Hookdeck source can receive a subscription and fan its events out to several destinations with [filters](/docs/filters) on `eventType`, so you need fewer subscriptions per event.

## Best practices

### Always set a secret

Without one, Swan sends no `x-swan-secret` header and your endpoint has nothing to check. Set one per subscription and per environment.

### Fetch, do not trust, the event

Treat each notification as a prompt to query the API for `resourceId`. That gives you the current state, which also deals with ordering and duplicates.

### Deduplicate on `eventId`

Store each `eventId` before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Separate endpoints by priority

Keep consent and account status changes away from transaction volume, so a backlog of `Transaction.Booked` never delays a `Consent.Granted`.

### Acknowledge fast and process asynchronously

Verify the secret, store the notification, return `200`, and make the API call afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Swan sends thin JSON notifications with the subscription's optional secret in the `x-swan-secret` header and no signature. Set a long random secret on every subscription, compare it in constant time over HTTPS, answer within 10 seconds, deduplicate on `eventId`, and read the current state of each resource from the API rather than relying on the order events arrive in.

[Hookdeck Event Gateway](https://hookdeck.com) checks the secret, answers Swan immediately and keeps every notification until your service has processed it, so a few minutes of downtime no longer runs out Swan's seven retries.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Swan webhooks reliably in minutes.