# Guide to Uphold Webhooks: Features and Best Practices

Uphold webhooks notify your platform when something changes for your users on Uphold's Enterprise APIs: a user is created, a KYC or KYB check changes status, an account balance moves, an external account is linked, or a transaction is created or completed. If you build a product on Uphold's Core API, webhooks keep your records in step with Uphold without polling.

This guide covers the Uphold Enterprise REST API webhooks: how they are delivered and signed, the event shape and event keys, retries and redelivery, and the best practices for running an Uphold receiver in production. Uphold's older consumer API had a different webhook scheme, which is not covered here.

## What are Uphold webhooks?

Uphold webhooks are delivered by [Svix](/webhooks/platforms/guide-to-svix-webhooks-features-and-best-practices) under Uphold's name. You subscribe to event types in the Svix-hosted Webhooks Portal, and each event arrives as a JSON `POST` with three headers: `Webhook-Id`, `Webhook-Timestamp` and `Webhook-Signature`.

Uphold uses the white-labelled `Webhook-` prefix rather than Svix's default `svix-`. The scheme behind the headers is Svix's, and Uphold's docs send you to Svix's verification guide for the details.

Every event has the same four top-level fields: `id`, `type`, `createdAt` and `data`.

## Uphold webhook features

| Feature | Details |
| --- | --- |
| Delivery platform | Svix, with `Webhook-*` headers |
| Configuration | Webhooks Portal, from the Enterprise Portal or the **Request webhook management link** API |
| Event shape | `{id, type, createdAt, data}` |
| Event field | Body `type`, for example `core.transaction.status-changed` |
| Headers | `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature` |
| Scheme | Base64 HMAC-SHA256 over `{Webhook-Id}.{Webhook-Timestamp}.{raw body}`, per Svix |
| Secret | A signing key unique to each receiving endpoint |
| Retries | Exponential backoff for up to 24 hours on a non-`2xx` response or timeout |
| Manual redelivery | Enterprise Portal, Webhooks section |
| Source IPs | Svix's published list |

## Common events

Each group of Core API endpoints has its own events, and the KYC Connector API adds a few more. The keys follow the pattern `<api>.<resource>.<action>`:

| Area | Event keys |
| --- | --- |
| Users | `core.user.created`, `core.user.deleted` |
| KYC | `core.kyc.<check>.status-changed` for `profile`, `email`, `phone`, `address`, `identity`, `proof-of-address`, `tax-details`, `customer-due-diligence`, `enhanced-due-diligence`, `crypto-risk-assessment`, `self-categorization-statement`, `screening` and `risk` |
| KYB | `core.kyb.<check>.status-changed` for `onboarding`, `profile`, `associated-persons`, `documents`, `financial-institution` and `attestation`, plus `core.kyb.associated-person.created`, `.deleted` and three per-person status events |
| Assets | `core.assets.cooldown-ended` |
| Accounts | `core.account.created`, `core.account.balance-changed`, `core.account.archived`, `core.account.unarchived` |
| External accounts | `core.external-account.created`, `.status-changed`, `.deleted` |
| Transactions | `core.transaction.created`, `core.transaction.status-changed` |
| KYC Connector | `kyc-connector.sumsub.ingestion.created`, `.status-changed`, and the same two for `veriff` |

Here is the `core.transaction.status-changed` example from Uphold's API spec, trimmed:

```json
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "type": "core.transaction.status-changed",
  "createdAt": "2021-01-01T00:00:00.000Z",
  "data": {
    "transaction": {
      "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0",
      "origin": {
        "amount": "100.00",
        "asset": "GBP",
        "node": { "type": "account", "id": "b98e4f0d-a67d-4126-a99d-666f7e0315d9" }
      },
      "destination": {
        "amount": "0.0021",
        "asset": "BTC",
        "node": { "type": "account", "id": "555a99a1-620d-4431-a0ac-b43e1aad2bdc" }
      },
      "status": "completed",
      "createdAt": "2024-07-24T15:22:39.000Z",
      "updatedAt": "2024-07-24T15:32:39.000Z"
    }
  }
}

```

`data` holds the object the event is about, such as `data.transaction`, `data.user` or `data.account`. Amounts are decimal strings, so parse them with a decimal type rather than a float.

Two of Uphold's own examples don't match the subscription keys. The overview's envelope example shows `"type": "core.users.created"`, while the key you subscribe to is `core.user.created`. The spec's only example for `core.account.balance-changed` shows `"type": "core.account.balance.changed"`. Until you have seen real deliveries, match those two leniently.

## Setting up Uphold webhooks

1. Open the Webhooks Portal, either from the **Webhooks** section of the Enterprise Portal or by calling the **Request webhook management link** endpoint, which returns a one-time login link.
2. Add an endpoint with your HTTPS URL and choose the event types to send to it.
3. Copy the endpoint's signing secret and store it server-side, for example as `UPHOLD_WEBHOOK_SECRET`. Svix-issued secrets start with `whsec_`; keep the prefix.
4. Use the portal's testing tools to send an example event, and its delivery logs to confirm the result.

If your endpoint is behind a firewall, allow Svix's published source IP addresses.

## Securing Uphold webhooks

Uphold doesn't restate the algorithm and refers you to Svix's verification guide. Following that guide: concatenate `Webhook-Id`, `Webhook-Timestamp` and the raw body with `.`, compute an HMAC-SHA256 using the base64-decoded part of the secret after `whsec_`, base64-encode the result, and compare it with each `v1,` entry in `Webhook-Signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyUpholdSignature(rawBody, headers, secret) {
  const msg_id = headers['webhook-id'];
  const msg_timestamp = headers['webhook-timestamp'];
  const signature_header = headers['webhook-signature'];
  if (!msg_id || !msg_timestamp || !signature_header) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The HMAC key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
    'utf8'
  );

  // The header can hold several space-separated "v1,<signature>" entries.
  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/uphold', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.UPHOLD_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyUpholdSignature(req.body, req.headers, secret)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, queue the event, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_uphold_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    msg_timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and msg_timestamp and signature_header):
        return False
    if not secret or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_uphold_signature(raw_body, request.headers, os.environ["UPHOLD_WEBHOOK_SECRET"])

```

The Svix SDKs and the Standard Webhooks libraries read the `webhook-*` header names directly, if you prefer a library. `Webhook-Timestamp` is signed, so your receiver can reject old deliveries, but Uphold states no tolerance.

> **Make Uphold webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Uphold's `Webhook-Signature`, queues every user, KYC and transaction event, and keeps it available for replay beyond Uphold's 24-hour retry window.

Hookdeck's Uphold source type verifies the `webhook-*` headers with the secret you enter, keeping its `whsec_` prefix. Hookdeck does not apply a timestamp window, so a delayed retry still verifies.

## Uphold webhook limitations and pain points

### Delivery IDs and event IDs are different things

**The Problem:** Deduplication misses repeats, or treats different events as the same one.

**Why It Happens:** `Webhook-Id` and `Webhook-Timestamp` are Svix's per-delivery values, while the body's `id` and `createdAt` come from Uphold's platform. Uphold's docs warn against confusing them. `Webhook-Id` stays the same when a webhook is resent, but `Webhook-Timestamp` is the time of sending.

**Workarounds:**

* Deduplicate on the body's `id` (or on `Webhook-Id`, which is stable across resends), never on `Webhook-Timestamp`.
* Use `createdAt` for event time and ordering.

**How Hookdeck Can Help:** Hookdeck's deduplication can key on the body's `id`, so retried deliveries are dropped before they reach your service.

### Example payloads that don't match the keys

**The Problem:** A handler built from the docs never matches account balance events.

**Why It Happens:** Two of Uphold's published examples spell `type` differently from the subscription key (`core.users.created`, `core.account.balance.changed`).

**Workarounds:**

* Match those event types on both spellings until you have seen real deliveries.
* Log any unrecognised `type` with its body so mismatches show up quickly.

**How Hookdeck Can Help:** Hookdeck keeps every request with its body and headers, so you can check the exact `type` strings Uphold sends and set up filters that match them.

### A 24-hour retry window

**The Problem:** An outage that lasts more than a day loses events.

**Why It Happens:** Uphold retries failed deliveries with exponential backoff for up to 24 hours, then stops.

**Workarounds:**

* Use manual redelivery in the Enterprise Portal for anything that failed.
* Alert on failed deliveries in the portal's logs.

**How Hookdeck Can Help:** Hookdeck accepts Uphold's deliveries while your service is down and keeps them for replay, so recovery doesn't depend on Uphold's retry window.

### Regulated data in transit

**The Problem:** KYC and KYB events tell you about identity checks, and you need to be sure they came from Uphold.

**Why It Happens:** Anyone can post to a public URL. The only proof of origin is the signature, plus Svix's IP ranges if you restrict by network.

**Workarounds:**

* Verify every request, fail closed when the secret is missing, and treat the event as a prompt to read the current KYC status from the Core API before granting access.

**How Hookdeck Can Help:** Hookdeck verifies each signature and stops unsigned or badly signed requests before they reach your service, so only verified events reach the services that handle user data.

## Best practices

### Verify against the raw body

Use `express.raw()` in Express, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after verification.

### Route on `type`

Switch on the body's `type` and log anything you don't recognise. Uphold's spec examples show the KYC and KYB status events carrying the new status and the user ID, so you can usually act without another API call.

### Acknowledge fast and process asynchronously

Uphold counts a timeout as a failure. Return a `2xx` as soon as the event is stored. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Retries and manual redelivery both resend events. Store the event `id` before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use decimals for amounts

Transaction and balance amounts are strings such as `"0.0021"`. Keep them as decimals end to end.

## Conclusion

Uphold's Enterprise API webhooks arrive through Svix with `Webhook-Id`, `Webhook-Timestamp` and `Webhook-Signature` headers and a consistent `{id, type, createdAt, data}` body. Verify each one against the raw body with the endpoint's `whsec_` secret, route on `type`, deduplicate on the event `id`, and plan for the 24-hour retry limit.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies Uphold's signatures, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Uphold webhooks reliably in minutes.