# Guide to Swipelux Webhooks: Features and Best Practices

[Swipelux](https://swipelux.com) 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](https://docs.swipelux.com/integration/webhooks) 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

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Body | JSON |
| Event routing | Body field `type` |
| Delivery platform | Svix (new integrations) |
| Configuration | API: `POST` and `PATCH /v3/webhooks` |
| Secret | Per endpoint, in the Svix portal via `GET /v3/webhooks/portal` |
| Portal link | Expires after one hour |
| Event ID | Body `id` |
| Guarantees | At least once; duplicates, delays and reordering expected |
| Recovery | Retry failed deliveries and replay windows from the portal |
| Retry schedule, timeout | Not documented |

## Events and payloads

The 12 v3 events:

| Event | Triggered When | Notable `data.object` fields |
| --- | --- | --- |
| `customer.created` | After a customer is created | `id`, `type` (e.g. `individual`), `externalId`, `changedSubresources` |
| `customer.updated` | A customer changes | same, `changedSubresources` lists what changed |
| `customer.archived` | A customer is archived | same plus `archivedAt` |
| `capability.created` | A capability is created | `id`, `customerId`, `method`, `accountType`, `status`, `statusReason`, `openTaskIds` |
| `capability.status_changed` | A capability's status changes after creation | same; `status` is `pending`, `ready`, `restricted`, `rejected` or `canceled` |
| `application.status_changed` | An institution application's status changes | `id`, `institution`, `status` (`requested`, `in_review`, `action_required`, `ready`, `rejected`, `disabled`, `canceled`), `statusReason` |
| `recipient.status_changed` | A recipient's status changes | `id`, `customerId`, `type`, `status` (`active`, `rejected`, `archived`) |
| `destination.status_changed` | A payout destination's status changes | `id`, `type`, `currency`, `status` (`in_review`, `ready`, `action_required`, `archived`), `details` |
| `account.created` | An account is created | `id`, `customerId`, `type`, `origin`, `status`, `currency`, `details` |
| `account.status_changed` | An account's status changes | same; `status` is `provisioning`, `in_review`, `ready`, `action_required`, `suspended`, `rejected` or `archived` |
| `account.details_changed` | An account's details change | same, with updated `details` and `routing` |
| `transfer.state_changed` | A v3 transfer changes public state | `id`, `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](https://github.com/hookdeck/webhook-samples/blob/main/providers/swipelux/latest/account.created.json):

```json
{
  "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`:
  
  
  ```bash
  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:

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

```

To receive events on your laptop while developing, register a [Hookdeck CLI](/docs/cli) URL:

```bash
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:

```javascript
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:

```javascript
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](/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 portal link expires

**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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) keeps those events stored and replayable outside the portal. [Sign up for free](https://dashboard.hookdeck.com/signup) and register a Swipelux source with `POST /v3/webhooks`.