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:
{
"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 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.
- In the Swan Dashboard, go to Developers > Webhooks.
- Add a subscription with a label, your HTTPS endpoint, the secret and the event types you need.
- Enable it.
The same subscription through the API:
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.
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:
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 checks the
x-swan-secretheader, 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 withreplayWebhookEvent. - 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 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
eventDatewith 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 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
eventIdyou 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 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.GrantedandAccount.Closingand another for high-volume ones such asTransaction.Booked.
How Hookdeck Can Help: One Hookdeck source can receive a subscription and fan its events out to several destinations with 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.
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.
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 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 for free and handle Swan webhooks reliably in minutes.