# Uphold Webhooks

[Uphold Enterprise](https://developer.uphold.com/rest-apis/webhooks) (the Core API at `api.enterprise.uphold.com`) sends near real-time webhook events for users, KYC/KYB, accounts, external accounts, and transactions. Deliveries are made by **Svix** under Uphold's name: the headers use the unbranded `webhook-` prefix (not `svix-`), and Uphold points to Svix's verification guide and SDKs.

> This skill covers the Enterprise REST APIs only. The legacy (pre-Enterprise) Uphold API has its own, older webhook scheme, which this skill does not cover.

## When to Use This Skill

* How do I receive Uphold webhooks?
* How do I verify an Uphold webhook signature?
* Where do I find my Uphold webhook signing secret?
* How do I handle `core.transaction.status-changed` or `core.user.created`?
* Why is my Uphold `Webhook-Signature` verification failing?
* How do I get into the Uphold Webhooks Portal (management link)?

## Verification (core)

| Header | Value |
| --- | --- |
| `Webhook-Id` | Unique message id for the delivery. The same value is reused when the delivery is retried |
| `Webhook-Timestamp` | Unix seconds when the delivery was sent |
| `Webhook-Signature` | Space-delimited list of `v1,<base64 HMAC-SHA256>` signatures |

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`. The key is the base64-**decoded** part of the endpoint secret after `whsec_`. The official `svix` SDK (npm and PyPI) accepts `webhook-*` headers directly. It enforces a 5-minute timestamp tolerance and compares in constant time.

```javascript
import { Webhook } from 'svix';

// rawBody: the exact request bytes as a string, NOT re-serialised JSON
function verifyUpholdWebhook(rawBody, headers) {
  new Webhook(process.env.UPHOLD_WEBHOOK_SECRET).verify(rawBody, {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  }); // throws WebhookVerificationError → respond 400
  // svix 2.x verify() returns undefined, so parse the body yourself
  return JSON.parse(rawBody);
}

```

Python: `from svix.webhooks import Webhook`, then `Webhook(secret).verify(await request.body(), headers)` followed by `json.loads(raw_body)`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/uphold-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/uphold-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/uphold-webhooks/examples/fastapi/).

## Event Envelope

```json
{ "id": "6a3d8cea-5250-4bd0-a443-eb07b04c85d4", "type": "core.transaction.status-changed",
  "createdAt": "2025-02-20T21:21:09.943Z",
  "data": { "transaction": { "id": "623000c8-...", "status": "completed", "...": "..." } } }

```

The payload `id` and `createdAt` are Uphold's event fields. The `Webhook-Id` and `Webhook-Timestamp` headers belong to the Svix delivery. Don't mix them up.

## Common Event Types

| Event | Fired when | `data` |
| --- | --- | --- |
| `core.user.created` / `core.user.deleted` | A user was created / deleted | `user` |
| `core.transaction.created` | A transaction was created | `transaction` |
| `core.transaction.status-changed` | Transaction status changed: `processing`, `on-hold`, `completed`, `failed` | `transaction` (with `statusDetails.reason` when failed or on hold) |
| `core.account.created` / `core.account.archived` / `core.account.unarchived` | Account lifecycle | `account` |
| `core.account.balance-changed` | An account's balance changed | `account.balance.{total,available}` |
| `core.external-account.created` / `.status-changed` / `.deleted` | Linked card, bank, APM or crypto account changes | `externalAccount` |
| `core.kyc.<process>.status-changed` | An individual's KYC process changed (`profile`, `email`, `phone`, `address`, `identity`, `proof-of-address`, `screening`, `risk`, ...) | `{ status, userId }` |
| `core.kyb.<process>.status-changed` | A business's KYB process changed | `{ status, userId }` |
| `core.assets.cooldown-ended` | An asset cooldown period ended | `{ userId, assets, cooldown }` |

KYC/KYB `status` is one of `ok`, `failed`, `running`, `pending`, `exempt`. [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/uphold-webhooks/references/overview.md) lists every event.

## Environment Variables

```bash
UPHOLD_WEBHOOK_SECRET=whsec_xxxxx   # endpoint signing secret from the Webhooks Portal

```

## Local Development

```bash
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 uphold --path /webhooks/uphold

```

Add the printed URL as an endpoint in the Webhooks Portal, then send a test event from the portal or trigger one with the Sandbox [test helpers](https://developer.uphold.com/rest-apis/test-helpers).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/uphold-webhooks/references/overview.md) - Full event list, payload shapes, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/uphold-webhooks/references/setup.md) - Opening the Webhooks Portal, adding endpoints, finding the secret, IPs
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/uphold-webhooks/references/verification.md) - SDK and manual verification, gotchas, debugging