Gareth Wilson Gareth Wilson

Guide to Swipelux Webhooks: Features and Best Practices

Published


Swipelux provides infrastructure for moving money between stablecoins and fiat: customers, verification capabilities, accounts and wallets, payout destinations and transfers, all through its v3 API. Its webhooks report when any of those changes state, for example when a customer's capability becomes ready or a transfer moves from processing to completed.

The sections below cover the v3 event envelope, endpoint management through the API, the short-lived portal link that holds your signing secret, and Swipelux's advice on ordering and refetching.

What are Swipelux webhooks?

Swipelux webhook endpoints are managed entirely through its API, and new integrations are delivered by Svix with svix-id, svix-timestamp and svix-signature headers. Swipelux's webhooks guide is explicit that delivery is at least once and that duplicates, delays and reordering are normal, so your receiver should treat events as hints and the API as the source of truth.

This guide applies only to those new integrations; legacy integrations sign their requests differently. Legacy event names appear as deprecated in the portal, and new subscriptions must use v3 names.

Swipelux webhook features

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature
BodyJSON
Event routingBody field type
Delivery platformSvix (new integrations)
ConfigurationAPI: POST and PATCH /v3/webhooks
SecretPer endpoint, in the Svix portal via GET /v3/webhooks/portal
Portal linkExpires after one hour
Event IDBody id
GuaranteesAt least once; duplicates, delays and reordering expected
RecoveryRetry failed deliveries and replay windows from the portal
Retry schedule, timeoutNot documented

Events and payloads

The 12 v3 events:

EventTriggered WhenNotable data.object fields
customer.createdAfter a customer is createdid, type (e.g. individual), externalId, changedSubresources
customer.updatedA customer changessame, changedSubresources lists what changed
customer.archivedA customer is archivedsame plus archivedAt
capability.createdA capability is createdid, customerId, method, accountType, status, statusReason, openTaskIds
capability.status_changedA capability's status changes after creationsame; status is pending, ready, restricted, rejected or canceled
application.status_changedAn institution application's status changesid, institution, status (requested, in_review, action_required, ready, rejected, disabled, canceled), statusReason
recipient.status_changedA recipient's status changesid, customerId, type, status (active, rejected, archived)
destination.status_changedA payout destination's status changesid, type, currency, status (in_review, ready, action_required, archived), details
account.createdAn account is createdid, customerId, type, origin, status, currency, details
account.status_changedAn account's status changessame; status is provisioning, in_review, ready, action_required, suspended, rejected or archived
account.details_changedAn account's details changesame, with updated details and routing
transfer.state_changedA v3 transfer changes public stateid, customerId, state (awaiting_funds, processing, action_required, completed, failed, canceled)

Transfers have a state; every other object has a status. An account.created event from the webhook samples library:

{
  "id": "evt_abc123",
  "type": "account.created",
  "createdAt": "2026-07-04T12:00:00.000Z",
  "attempt": 1,
  "resource": {
    "type": "account",
    "id": "acc_abc123"
  },
  "data": {
    "object": {
      "id": "acc_abc123",
      "customerId": "cus_abc123",
      "type": "wallet",
      "network": "base",
      "origin": "issued",
      "status": "ready",
      "openTaskIds": [],
      "currency": "USDC",
      "isDefault": true,
      "details": {
        "network": "base",
        "address": "0xabc"
      },
      "fees": {
        "breakdown": {
          "developer": null
        }
      },
      "createdAt": "2026-07-04T12:00:00.000Z",
      "updatedAt": "2026-07-04T12:00:00.000Z",
      "archivedAt": null
    }
  }
}
  • id is the event ID. Swipelux recommends a uniqueness constraint on it.
  • type is the event name. resource.type is the kind of object, such as transfer, and resource.id its ID.
  • attempt is currently always 1 and doesn't change on retries, so it's neither a retry counter nor a deduplication key.
  • data.object is the object's state at the time of the event. data.previous holds the changed field, such as { "state": "..." }, on *.status_changed and transfer.state_changed events only.

Setting up Swipelux webhooks

You need an API key with webhooks:write (for creating endpoints, opening the portal and using retry or replay) or webhooks:read (for reading endpoints).

  1. Create an endpoint, subscribing only to what you need, with an Idempotency-Key:

    export API_BASE='https://platform.swipelux.com'
    export SWIPELUX_API_KEY='replace-with-your-api-key'
    curl --request POST "${API_BASE}/v3/webhooks" \
      --header "X-API-Key: ${SWIPELUX_API_KEY}" \
      --header "Idempotency-Key: webhook-endpoint-001" \
      --header "Content-Type: application/json" \
      --data '{"url": "https://example.com/webhooks/swipelux", "events": ["transfer.state_changed"]}'
    

    Keep the response's data.id as the webhook ID.

  2. Call GET /v3/webhooks/portal with a webhooks:write key. It returns a link to the Svix app portal that works on its own and expires after an hour, so request a fresh one each time and don't store or share it.

  3. In the portal, reveal the endpoint's signing secret and store it as SWIPELUX_WEBHOOK_SECRET, separately from your API key. GET /v3/webhooks/{webhookId} never returns it. Swipelux doesn't print the format, but Svix secrets start with whsec_.

To change subscriptions, PATCH /v3/webhooks/{webhookId} with events, url or both. events replaces the whole selection, so read the endpoint first and send the full list:

{ "events": ["transfer.state_changed", "capability.status_changed"] }

To receive events on your laptop while developing, register a Hookdeck CLI URL:

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

With Hookdeck. Register a Swipelux source's URL with POST /v3/webhooks and paste the secret from the portal into it. Only new Swipelux webhook integrations, delivered by Svix, are supported. Legacy integrations use a different scheme and fail verification. The source takes POST requests only, sends Swipelux a 200 each time, and records any request that fails verification without passing it on.

Securing Swipelux webhooks

Swipelux relies on Svix's verification scheme: HMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, keyed with the base64-decoded secret after whsec_, compared against each v1, value in svix-signature. Swipelux asks you to keep the raw body available until verification completes. Without a library:

const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

Following Swipelux's advice, this handler stores the verified event once and refetches the object before acting:

const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/swipelux', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.SWIPELUX_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (!event.id || typeof event.type !== 'string') return res.sendStatus(400);
  // A unique constraint on event.id makes repeats a no-op.
  let inserted;
  try {
    inserted = await inbox.insertUnique(event.id, event.type, event.resource);
  } catch {
    return res.sendStatus(503); // not stored, so don't acknowledge
  }
  // The event is stored; run a periodic sweep of unprocessed rows to catch a failed schedule.
  if (inserted) await worker.schedule(event.id).catch(() => {});
  return res.sendStatus(200);
});

inbox and worker are your own storage and job queue. For transfer.state_changed, the worker calls GET /v3/transfers/{transferId} with event.resource.id and updates what the customer sees from that response, not from the event.

The Svix libraries reject a svix-timestamp more than five minutes old. Hookdeck applies no such limit, and its retries and replays carry Swipelux's original timestamp, so the library check would fail on them. Use the function above, which checks only the signature, behind Hookdeck.

Make Swipelux webhooks production-ready. Hookdeck Event Gateway stores each transfer and account event, and lets you replay it without opening a fresh portal link every time.

Swipelux webhook limitations and pain points

Legacy integrations don't verify

The Problem: Deliveries fail Standard Webhooks verification.

Why It Happens: Only new, Svix-delivered integrations use this scheme. Legacy integrations sign differently.

Workarounds:

  • Create a new v3 endpoint with v3 event names and move consumers to it.

How Hookdeck Can Help: Hookdeck's request log shows the headers each delivery carries, which makes a legacy endpoint easy to identify.

The Problem: You need the signing secret or a replay and the link you saved doesn't work.

Why It Happens: GET /v3/webhooks/portal returns a link valid for one hour.

Workarounds:

  • Generate the link only when needed, and copy the secret straight into your secret manager.

How Hookdeck Can Help: Day-to-day inspection and replay can happen in Hookdeck, so the portal is only needed for setup and rotation.

PATCH replaces the event list

The Problem: Adding one event silently unsubscribes the others.

Why It Happens: events in PATCH /v3/webhooks/{webhookId} replaces the whole selection.

Workarounds:

  • Read the endpoint, merge the list, then PATCH the complete set.

How Hookdeck Can Help: Subscribe the endpoint to everything you might need and use Hookdeck filters to choose what each service receives.

Order isn't authoritative

The Problem: A transfer shows processing after it already showed completed.

Why It Happens: Swipelux says duplicates, delays and reordering are normal.

Workarounds:

  • Refetch the object by resource.id before updating anything customer-facing.

How Hookdeck Can Help: Deduplication on the body id removes repeats, but refetching stays the way to get order right.

Best practices

Persist, acknowledge, then process

Verify, insert the envelope with a unique constraint on id, return 2xx, and do the work afterwards. A replay reuses the original event ID, so a completed event stays a no-op. Our webhook idempotency guide explains the pattern.

Drive status from the API

Use the event to know what changed, and the API response to know the current state.

Keep keys scoped

Use a webhooks:read key for monitoring and reserve webhooks:write for setup, since it can open the portal.

Store the secret apart from the API key

They protect different things. Keep both server-side, in separate entries.

Conclusion

Swipelux's v3 webhooks report every change to customers, capabilities, accounts and stablecoin transfers. Create endpoints with v3 names, fetch the secret through the short-lived portal link, deduplicate on id, and refetch objects before showing customers a status.

Hookdeck Event Gateway keeps those events stored and replayable outside the portal. Sign up for free and register a Swipelux source with POST /v3/webhooks.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.