Agent skill

Swan Webhooks Skill

Receive and authenticate Swan webhooks. Use when setting up a Swan embedded banking webhook endpoint, checking the x-swan-secret header, debugging 401s, or handling Transaction.Booked, Account.Created, AccountHolder.Updated, Card.Created and Onboarding.Updated events.

Install this skill

npx skills add hookdeck/webhook-skills --skill swan-webhooks


Swan, the embedded banking platform, sends a thin JSON webhook when a resource changes in your project: a transaction is booked, an account or card is created, an onboarding is updated. Webhooks are not signed. If the subscription has a webhook secret, Swan sends it as plain text in the x-swan-secret header of every request, and your endpoint authenticates the request by comparing that header with the secret you stored.

Set a webhook secret on the Swan webhook subscription. Subscriptions without one send nothing to verify.

When to Use This Skill

  • How do I receive Swan webhooks?
  • How do I verify the x-swan-secret header?
  • Does Swan sign webhook payloads? (No: use the shared secret)
  • How do I handle Transaction.Booked, Account.Created or Onboarding.Updated events?
  • Why does my Swan webhook endpoint return 401, or why is there no x-swan-secret header?

Verification (core)

const crypto = require('crypto');

// timingSafeEqual throws on different lengths, so check them first.
function safeEqual(a, b) {
  const ba = Buffer.from(String(a), 'utf8');
  const bb = Buffer.from(String(b), 'utf8');
  return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}

function verifySwanRequest(headers, secret = process.env.SWAN_WEBHOOK_SECRET) {
  if (!secret) return false;                  // never accept when unconfigured
  const received = headers['x-swan-secret'];  // the secret, plain text, whole value
  return typeof received === 'string' && received.length > 0 && safeEqual(received, secret);
}
  • Header: x-swan-secret: <secret> (case-insensitive; compare the whole value, no prefix).
  • In Python use hmac.compare_digest on bytes (.encode("utf-8") both sides; str raises on non-ASCII).
  • Return 401 on a missing or different header, 400 for a body that isn't a JSON object, 200 once handled.
  • Read the body raw (express.raw({ type: '*/*' })) and authenticate before parsing it.
  • A static shared secret authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Use HTTPS, deduplicate on eventId, and rotate the secret if it leaks.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Notification Payload

Notifications are thin. Query the Swan API with resourceId for the details.

{ "eventType": "Transaction.Booked", "eventId": "...", "eventDate": "2021-08-18T09:35:46.673Z", "projectId": "...", "resourceId": "..." }
  • Respond 200 OK within 10 seconds; errors are retried every 30 seconds, up to 7 times.
  • Delivery is at-least-once and unordered: deduplicate on eventId and don't assume order.

Event Types

eventType is <Resource>.<Action>.

EventSent When
Transaction.BookedA transaction is booked
Transaction.PendingA transaction is pending
Transaction.RejectedA transaction is rejected
Account.CreatedAn account is created
AccountHolder.UpdatedAn account holder is updated
Card.CreatedA card is created
Onboarding.UpdatedAn onboarding is updated

Swan documents many more (Consent.Granted, Account.Closing, MerchantPayment.Captured, ...); see references/overview.md.

Environment Variables

SWAN_WEBHOOK_SECRET=your-webhook-secret   # the secret set on the Swan webhook subscription

Local Development

Swan can't reach localhost. Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 swan --path /webhooks/swan

Use the printed URL as the endpoint of the Swan webhook subscription.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →